diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 37011e6792..cbf130dc8d 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,5 +1,5 @@ # [Choice] Node.js version (use -bullseye variants on local arm64/Apple Silicon): 18, 16, 14, 18-bullseye, 16-bullseye, 14-bullseye, 18-buster, 16-buster, 14-buster -ARG VARIANT=18-bullseye +ARG VARIANT=20 FROM mcr.microsoft.com/vscode/devcontainers/javascript-node:0-${VARIANT} # [Optional] Uncomment this section to install additional OS packages. @@ -13,4 +13,4 @@ FROM mcr.microsoft.com/vscode/devcontainers/javascript-node:0-${VARIANT} # [Optional] Uncomment if you want to install more global node modules # RUN su node -c "npm install -g " -RUN su node -c "npm install -g pnpm" \ No newline at end of file +RUN su node -c "npm install -g pnpm" diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index a87d72d4e2..eace7d5bd2 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -2,29 +2,27 @@ // https://github.com/microsoft/vscode-dev-containers/tree/v0.245.2/containers/javascript-node-postgres // Update the VARIANT arg in docker-compose.yml to pick a Node.js version { - "name": "Node.js & PostgreSQL", - "dockerComposeFile": "docker-compose.yml", - "service": "app", - "workspaceFolder": "/workspace", + "name": "Node.js & PostgreSQL", + "dockerComposeFile": "docker-compose.yml", + "service": "app", + "workspaceFolder": "/workspace", - // Configure tool-specific properties. - "customizations": { - // Configure properties specific to VS Code. - "vscode": { - // Add the IDs of extensions you want installed when the container is created. - "extensions": [ - "dbaeumer.vscode-eslint" - ] - } - }, + // Configure tool-specific properties. + "customizations": { + // Configure properties specific to VS Code. + "vscode": { + // Add the IDs of extensions you want installed when the container is created. + "extensions": ["dbaeumer.vscode-eslint"] + } + }, - // Use 'forwardPorts' to make a list of ports inside the container available locally. - // This can be used to network with other containers or with the host. - "forwardPorts": [3000, 5432, 8025], + // Use 'forwardPorts' to make a list of ports inside the container available locally. + // This can be used to network with other containers or with the host. + "forwardPorts": [3000, 5432, 8025], - // Use 'postCreateCommand' to run commands after the container is created. - "postCreateCommand": "pnpm install", + // Use 'postCreateCommand' to run commands after the container is created. + "postCreateCommand": "cp .env.example .env && sed -i '/^ENCRYPTION_KEY=/c\\ENCRYPTION_KEY='$(openssl rand -hex 32) .env && sed -i '/^NEXTAUTH_SECRET=/c\\NEXTAUTH_SECRET='$(openssl rand -hex 32) .env && pnpm install && pnpm db:migrate:dev", - // Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root. - "remoteUser": "node" + // Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root. + "remoteUser": "node" } diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index 4c8a4cf2e2..cc35a8d7fb 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -6,10 +6,10 @@ services: context: . dockerfile: Dockerfile args: - # Update 'VARIANT' to pick an LTS version of Node.js: 18, 16, 14. + # Update 'VARIANT' to pick an LTS version of Node.js: 20, 18, 16, 14. # Append -bullseye or -buster to pin to an OS version. # Use -bullseye variants on local arm64/Apple Silicon. - VARIANT: "18" + VARIANT: "20" volumes: - ..:/workspace:cached @@ -33,7 +33,7 @@ services: environment: POSTGRES_PASSWORD: postgres POSTGRES_USER: postgres - POSTGRES_DB: postgres + POSTGRES_DB: formbricks # Add "forwardPorts": ["5432"] to **devcontainer.json** to forward PostgreSQL locally. # (Adding the "ports" property to this file will not forward from a Codespace.) diff --git a/.env.example b/.env.example index 3334afca0f..cde8a0225d 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,3 @@ -/* ######################################################################## # ------------ MANDATORY (CHANGE ACCORDING TO YOUR SETUP) ------------# ######################################################################## @@ -10,18 +9,13 @@ WEBAPP_URL=http://localhost:3000 -SURVEY_BASE_URL=http://localhost:3000/s - # Set this if you want to have a shorter link for surveys -SHORT_SURVEY_BASE_URL= +SHORT_URL_BASE= # Encryption keys # Please set both for now, we will change this in the future -# You can use: `openssl rand -base64 16` to generate one -FORMBRICKS_ENCRYPTION_KEY= - -# You can use: `openssl rand -base64 24` to generate one +# You can use: `openssl rand -hex 32` to generate one ENCRYPTION_KEY= ############## @@ -35,7 +29,7 @@ DATABASE_URL='postgresql://postgres:postgres@localhost:5432/formbricks?schema=pu ############### # @see: https://next-auth.js.org/configuration/options#nextauth_secret -# You can use: `openssl rand -base64 32` to generate one +# You can use: `openssl rand -hex 32` to generate one NEXTAUTH_SECRET=RANDOM_STRING # Set this to your public-facing URL, e.g., https://example.com @@ -69,10 +63,10 @@ SMTP_PASSWORD=smtpPassword ##################### # Email Verification. If you enable Email Verification you have to setup SMTP-Settings, too. -# EMAIL_VERIFICATION_DISABLED=1 +EMAIL_VERIFICATION_DISABLED=1 # Password Reset. If you enable Password Reset functionality you have to setup SMTP-Settings, too. -# PASSWORD_RESET_DISABLED=1 +PASSWORD_RESET_DISABLED=1 # Signup. Disable the ability for new users to create an account. # SIGNUP_DISABLED=1 @@ -99,12 +93,22 @@ GOOGLE_AUTH_ENABLED=0 GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= +# Configure Azure Active Directory Login +AZUREAD_AUTH_ENABLED=0 +AZUREAD_CLIENT_ID= +AZUREAD_CLIENT_SECRET= +AZUREAD_TENANT_ID= + # Cron Secret CRON_SECRET= # Configure this when you want to ship JS & CSS files from a complete URL instead of the current domain # ASSET_PREFIX_URL= +# Oauth credentials for Notion Integration +NOTION_OAUTH_CLIENT_ID= +NOTION_OAUTH_CLIENT_SECRET= + # Stripe Billing Variables STRIPE_SECRET_KEY= STRIPE_WEBHOOK_SECRET= @@ -114,4 +118,22 @@ NEXT_PUBLIC_FORMBRICKS_API_HOST= NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID= NEXT_PUBLIC_FORMBRICKS_ONBOARDING_SURVEY_ID= -*/ +# Oauth credentials for Google sheet integration +GOOGLE_SHEETS_CLIENT_ID= +GOOGLE_SHEETS_CLIENT_SECRET= +GOOGLE_SHEETS_REDIRECT_URL= + +# Oauth credentials for Airtable integration +AIRTABLE_CLIENT_ID= + +# Enterprise License Key +ENTERPRISE_LICENSE_KEY= + +# Automatically assign new users to a specific team and role within that team +# Insert an existing team id or generate a valid CUID for a new one at https://www.getuniqueid.com/cuid (e.g. cjld2cjxh0000qzrmn831i7rn) +# (Role Management is an Enterprise feature) +# DEFAULT_TEAM_ID= +# DEFAULT_TEAM_ROLE=admin + +# set to 1 to skip onboarding for new users +# ONBOARDING_DISABLED=1 diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index fa0d33dbca..2e2c67543c 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -42,6 +42,7 @@ Fixes # (issue) - [ ] Removed all `console.logs` - [ ] Merged the latest changes from main onto my branch with `git pull origin main` - [ ] My changes don't cause any responsiveness issues +- [ ] First PR at Formbricks? [Please sign the CLA!](https://formbricks.com/clmyhzfrymr4ko00hycsg1tvx) Without it we wont be able to merge it 🙏 ### Appreciated diff --git a/.github/workflows/build-formbricks-com.yml b/.github/workflows/build-formbricks-com.yml new file mode 100644 index 0000000000..9720f739b8 --- /dev/null +++ b/.github/workflows/build-formbricks-com.yml @@ -0,0 +1,26 @@ +name: Build +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 18.x + uses: actions/setup-node@v3 + with: + node-version: 18.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... diff --git a/.github/workflows/build.yml b/.github/workflows/build-web.yml similarity index 94% rename from .github/workflows/build.yml rename to .github/workflows/build-web.yml index 4754bfeb63..f8b7d23c3d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build-web.yml @@ -27,7 +27,7 @@ jobs: - name: Generate Random NEXTAUTH_SECRET run: | - SECRET=$(openssl rand -base64 24) + SECRET=$(openssl rand -hex 32) echo "ENCRYPTION_KEY=$SECRET" >> $GITHUB_ENV - name: Build Formbricks-web diff --git a/.github/workflows/cron-reportUsageToStripe.yml b/.github/workflows/cron-reportUsageToStripe.yml new file mode 100644 index 0000000000..8751f56e4d --- /dev/null +++ b/.github/workflows/cron-reportUsageToStripe.yml @@ -0,0 +1,23 @@ +name: Cron - reportUsageToStripe + +on: + # "Scheduled workflows run on the latest commit on the default or base branch." + # — https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows#schedule + schedule: + # This will run the job at 22:00 UTC every day of every month. + - cron: "0 22 * * *" +jobs: + cron-reportUsageToStripe: + env: + APP_URL: ${{ secrets.APP_URL }} + CRON_SECRET: ${{ secrets.CRON_SECRET }} + runs-on: ubuntu-latest + steps: + - name: cURL request + if: ${{ env.APP_URL && env.CRON_SECRET }} + run: | + curl ${{ env.APP_URL }}/api/cron/report-usage \ + -X POST \ + -H 'x-api-key: ${{ env.CRON_SECRET }}' \ + -H 'Cache-Control: no-cache' \ + --fail diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 5297463caf..b29aaf8c15 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -25,9 +25,9 @@ jobs: - name: create .env run: cp .env.example .env - - name: Generate Random NEXTAUTH_SECRET + - name: Generate Random ENCRYPTION_KEY run: | - SECRET=$(openssl rand -base64 24) + SECRET=$(openssl rand -hex 32) echo "ENCRYPTION_KEY=$SECRET" >> $GITHUB_ENV - name: Lint diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml new file mode 100644 index 0000000000..caa270d414 --- /dev/null +++ b/.github/workflows/playwright.yml @@ -0,0 +1,40 @@ +name: E2E Tests +on: + workflow_call: +jobs: + build: + name: Run E2E Tests + runs-on: ubuntu-latest + timeout-minutes: 60 + + steps: + - uses: actions/checkout@v3 + + - uses: actions/setup-node@v3 + with: + node-version: 20 + + - name: Install Docker Compose + run: sudo apt-get update && sudo apt-get install -y docker-compose + + - name: Install dependencies + run: npm install -g pnpm && pnpm install + + - name: Build Formricks JS package + run: pnpm build --filter=js + + - name: Build Formbricks Image & Run + run: docker-compose up -d + + - name: Install Playwright Browsers + run: pnpm exec playwright install --with-deps + + - name: Run Playwright tests + run: pnpm test:e2e + + - uses: actions/upload-artifact@v3 + if: always() + with: + name: playwright-report + path: playwright-report/ + retention-days: 30 diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 09a70f400d..7ef24b7473 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -24,11 +24,16 @@ jobs: build: name: Build Formbricks-web - uses: ./.github/workflows/build.yml + uses: ./.github/workflows/build-web.yml + secrets: inherit + + e2e-test: + name: Run E2E Tests + uses: ./.github/workflows/playwright.yml secrets: inherit required: - needs: [lint, test, build] + needs: [lint, test, build, e2e-test] if: always() runs-on: ubuntu-latest steps: diff --git a/.github/workflows/release-docker-github.yml b/.github/workflows/release-docker-github.yml new file mode 100644 index 0000000000..0dddc0fd41 --- /dev/null +++ b/.github/workflows/release-docker-github.yml @@ -0,0 +1,116 @@ +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. + +on: + workflow_dispatch: + push: + tags: + - "v*" + +env: + # Use docker.io for Docker Hub if empty + REGISTRY: ghcr.io + # github.repository as / + IMAGE_NAME: ${{ github.repository }} + TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} + TURBO_TEAM: ${{ secrets.TURBO_TEAM }} + DATABASE_URL: "postgresql://postgres:postgres@localhost:5432/formbricks?schema=public" + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + # This is used to complete the identity challenge + # with sigstore/fulcio when running outside of PRs. + id-token: write + + steps: + - name: Generate Random NEXTAUTH_SECRET + run: | + SECRET=$(openssl rand -hex 32) + echo "NEXTAUTH_SECRET=$SECRET" >> $GITHUB_ENV + + - name: Generate Random ENCRYPTION_KEY + run: | + SECRET=$(openssl rand -hex 32) + echo "ENCRYPTION_KEY=$SECRET" >> $GITHUB_ENV + + - name: Checkout repository + uses: actions/checkout@v3 + + # Install the cosign tool except on PR + # https://github.com/sigstore/cosign-installer + - name: Install cosign + if: github.event_name != 'pull_request' + uses: sigstore/cosign-installer@6e04d228eb30da1757ee4e1dd75a0ec73a653e06 #v3.1.1 + with: + cosign-release: "v2.1.1" + + # Add support for more platforms with QEMU (optional) + # https://github.com/docker/setup-qemu-action + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + # Set up BuildKit Docker container builder to be able to build + # multi-platform images and export cache + # https://github.com/docker/setup-buildx-action + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 # v3.0.0 + + # Login against a Docker registry except on PR + # https://github.com/docker/login-action + - name: Log into registry ${{ env.REGISTRY }} + if: github.event_name != 'pull_request' + uses: docker/login-action@v3 # v3.0.0 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # Extract metadata (tags, labels) for Docker + # https://github.com/docker/metadata-action + - name: Extract Docker metadata + id: meta + uses: docker/metadata-action@v5 # v5.0.0 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + + # Build and push Docker image with Buildx (don't push on PR) + # https://github.com/docker/build-push-action + - name: Build and push Docker image + id: build-and-push + uses: docker/build-push-action@v5 # v5.0.0 + with: + context: . + file: ./apps/web/Dockerfile + # platforms: linux/amd64,linux/arm64 + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + build-args: | + NEXTAUTH_SECRET=${{ env.NEXTAUTH_SECRET }} + DATABASE_URL=${{ env.DATABASE_URL }} + ENCRYPTION_KEY=${{ env.ENCRYPTION_KEY }} + + # Sign the resulting Docker image digest except on PRs. + # This will only write to the public Rekor transparency log when the Docker + # repository is public to avoid leaking data. If you would like to publish + # transparency data even for private images, pass --force to cosign below. + # https://github.com/sigstore/cosign + - name: Sign the published Docker image + if: ${{ github.event_name != 'pull_request' }} + env: + # https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-an-intermediate-environment-variable + TAGS: ${{ steps.meta.outputs.tags }} + DIGEST: ${{ steps.build-and-push.outputs.digest }} + # This step uses the identity token to provision an ephemeral certificate + # against the sigstore community Fulcio instance. + run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST} diff --git a/.github/workflows/release-docker.yml b/.github/workflows/release-docker.yml index 32f6d20650..8cfd44098c 100644 --- a/.github/workflows/release-docker.yml +++ b/.github/workflows/release-docker.yml @@ -16,12 +16,12 @@ jobs: steps: - name: Generate Random NEXTAUTH_SECRET run: | - SECRET=$(openssl rand -hex 16) + SECRET=$(openssl rand -hex 32) echo "NEXTAUTH_SECRET=$SECRET" >> $GITHUB_ENV - - name: Generate Random NEXTAUTH_SECRET + - name: Generate Random ENCRYPTION_KEY run: | - SECRET=$(openssl rand -base64 24) + SECRET=$(openssl rand -hex 32) echo "ENCRYPTION_KEY=$SECRET" >> $GITHUB_ENV - name: Checkout Repo @@ -55,3 +55,4 @@ jobs: build-args: | NEXTAUTH_SECRET=${{ env.NEXTAUTH_SECRET }} DATABASE_URL=${{ env.DATABASE_URL }} + ENCRYPTION_KEY=${{ env.ENCRYPTION_KEY }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e5ae506cb9..03667c2a3f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -29,6 +29,11 @@ jobs: - name: create .env run: cp .env.example .env + - name: Generate Random ENCRYPTION_KEY + run: | + SECRET=$(openssl rand -hex 32) + echo "ENCRYPTION_KEY=$SECRET" >> $GITHUB_ENV + - name: Build formbricks-js dependencies run: pnpm build --filter=js diff --git a/.gitignore b/.gitignore index 2162d9e5ad..d0da3c7805 100644 --- a/.gitignore +++ b/.gitignore @@ -44,4 +44,10 @@ packages/database/zod # nixos stuff .direnv -Zone.Identifier \ No newline at end of file +Zone.Identifier + +# Playwright +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ diff --git a/.gitpod.yml b/.gitpod.yml index bba02a483d..a7fe60cea2 100644 --- a/.gitpod.yml +++ b/.gitpod.yml @@ -10,7 +10,7 @@ tasks: gp sync-await init && turbo --filter "@formbricks/demo" go - - name : website + - name: website command: gp sync-await init && turbo --filter "@formbricks/formbricks-com" dev - name: Init Formbricks @@ -34,12 +34,10 @@ tasks: cp .env.example .env && sed -i -r "s#^(WEBAPP_URL=).*#\1 $(gp url 3000)#" .env && sed -i -r "s#^(NEXTAUTH_URL=).*#\1 $(gp url 3000)#" .env && - RANDOM_FORMBRICKS_ENCRYPTION_KEY=$(openssl rand -base64 16) - sed -i 's/^FORMBRICKS_ENCRYPTION_KEY=.*/FORMBRICKS_ENCRYPTION_KEY='"$RANDOM_FORMBRICKS_ENCRYPTION_KEY"'/' .env - RANDOM_ENCRYPTION_KEY=$(openssl rand -base64 24) + RANDOM_ENCRYPTION_KEY=$(openssl rand -hex 32) sed -i 's/^ENCRYPTION_KEY=.*/ENCRYPTION_KEY='"$RANDOM_ENCRYPTION_KEY"'/' .env turbo --filter "@formbricks/web" go - + image: file: .gitpod.Dockerfile @@ -62,7 +60,7 @@ ports: - port: 8025 visibility: public onOpen: open-browser - + github: prebuilds: master: true @@ -77,4 +75,4 @@ vscode: - "dbaeumer.vscode-eslint" - "esbenp.prettier-vscode" - "Prisma.prisma" - - "yzhang.markdown-all-in-one" \ No newline at end of file + - "yzhang.markdown-all-in-one" diff --git a/.prettierrc.js b/.prettierrc.js index db8e3eee54..5430f66ec3 100644 --- a/.prettierrc.js +++ b/.prettierrc.js @@ -1 +1,6 @@ -module.exports = require("./packages/prettier-config/prettier-preset"); +const baseConfig = require("./packages/prettier-config/prettier-preset"); + +module.exports = { + ...baseConfig, + plugins: ["@trivago/prettier-plugin-sort-imports", "prettier-plugin-tailwindcss"], +}; diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000000..49d10baaac --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,69 @@ +# Code of Conduct + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to make participation in Formbricks and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment include: + +- Using welcoming and inclusive language +- Being respectful of differing viewpoints and experiences +- Gracefully accepting constructive criticism +- Focusing on what is best for the community +- Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +- The use of sexualized language or imagery and unwelcome sexual attention or advances +- Trolling, insulting/derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or electronic address, without explicit permission +- Other conduct that could reasonably be considered inappropriate in a professional setting + +## Our Responsibilities + +We as project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. + +We have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned with this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project email address, posting via an official social media account, or acting as an appointed representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at hola@formbricks.com - all complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. + +## Enforcement Guidelines + +Community managers will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of actions. + +**Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public of private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the community. + +## Attribution + +This Code of Conduct is adapted from the Contributor Covenant, version 2.0, available at [https://www.contributor-covenant.org/version/2/0/code_of_conduct.html](https://www.contributor-covenant.org/version/2/0/code_of_conduct.html). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 95b03a520d..403cef9117 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,29 +1,33 @@ -We are so happy that you are interested in contributing to Formbricks 🤗 +# 🚀 Join the Formbricks Tribe! 🧱 -There are many ways to contribute to Formbricks with writing Issues, fixing bugs, building new features or updating the docs. +First and foremost, we're absolutely thrilled that you're considering becoming a part of the Formbricks Tribe! 🤗 -# Issues +Discover a myriad of ways to leave your mark on Formbricks — whether it's by squashing bugs, crafting new features, or enhancing our documentation. -Spotted a bug? Has deployment gone wrong? Do you have user feedback? [Raise an issue](https://github.com/formbricks/formbricks/issues/new/choose) for the fastest response. +## 🐛 Issue Hunters -... or pick up and fix an issue if you want to do a Pull Request. +Did you stumble upon a bug? Encountered a hiccup in deployment? Perhaps you have some user feedback to share? Your quickest route to help us out is by [raising an issue](https://github.com/formbricks/formbricks/issues/new/choose). We're on standby to respond swiftly. -# Feature requests +## 💡 Feature Architects -Raise an issue for these and tag it as an Enhancement. We love every idea. Please give us as much context on the why as possible. +Are you brimming with brilliant ideas? For new features that can elevate Formbricks, create an issue and slap on the "Enhancement" tag. We adore every concept that you throw our way. Just make sure to provide us with the "why" behind your idea. We're all ears! -# Creating a PR +## 🛠 Crafting Pull Requests -Please fork the repository, make your changes and create a new pull request if you want to make an update. +Ready to dive into the code and make a real impact? Here's your path: -If you want to speak to us before doing lots of work, please join our [Discord server](https://formbricks.com/discord) and tell us what you would like to work on - we're very responsive and friendly! +1. **Read our Best Practices**: [It takes 5 minutes](https://formbricks.com/docs/contributing/how-we-code) but will help you save hours 🤓 -For QA of your Pull-Request, you can also get in touch with Matti on Discord. But we will also get to your PR without you taking additional action ;-) +1. **Fork the Repository:** Fork our repository or use [Gitpod](https://formbricks.com/docs/contributing/gitpod) -# Features +1. **Tweak and Transform:** Work your coding magic and apply your changes. -We are currently working on having a clear [Roadmap](https://github.com/orgs/formbricks/projects/1) for the next steps ahead. +1. **Pull Request Act:** If you're ready to go, craft a new pull request closely following our PR template 🙏 -But you can also pick a feature that is not already on the roadmap if you think it creates a positive impact for Formbricks. +Would you prefer a chat before you dive into a lot of work? Our [Discord server](https://formbricks.com/discord) is your harbor. Share your thoughts, and we'll meet you there with open arms. We're responsive and friendly, promise! -If you are at all unsure, just raise it as an enhancement issue first and tell us that you like to work on it, and we'll very quickly respond. +## 🚀 Aspiring Features + +If you spot a feature that isn't part of our official plan but could propel Formbricks forward, don't hesitate. Raise it as an enhancement issue, and let us know you're ready to take the lead. We'll be quick to respond. + +Together, let's craft the future of Formbricks, making it better, bolder, and more brilliant! 🚀🧱🌟 diff --git a/README.md b/README.md index b4b6d83444..cc61731956 100644 --- a/README.md +++ b/README.md @@ -1,83 +1,128 @@ -

- - Open Source Experience Management Solution Qualtrics Alternative Logo - -

Formbricks

+
-

- The Open Source Survey & Experience Management solution for fast growing companies -
- Website | Join Discord community -

+

+ + + +Open Source Privacy First Experience Management Solution Qualtrics Alternative Logo + + + +

Formbricks

+ +

+Harvest user-insights, build irresistible experiences. +
+Website | Join Discord community +

-

-License Join Formbricks Discord Github Stars - Hacker News - Product Hunt - Github Accelerator - +

+License Join Formbricks Discord Github Stars +Hacker News +Product Hunt +Github Accelerator +


-

+

+

Trusted by      -      -      -      - +      +      +      +      +

+
-formtribe hackathon - -## 🔥 The FormTribe Hackathon is on! - -To celebrate Hacktoberfest, we've launched our FormTribe hackathon. Write code or perform non-code side quests to collect points and increase your chances of winning the MacBook Air M2! - -**Join lottery with a [single tweet!](https://formtribe.com). All info on [formtribe.com](https://formtribe.com)** +

+Trendshift Badge for formbricks/formbricks +

## ✨ About Formbricks -formbricks-sneak +formbricks-sneak -Formbricks is your go-to solution for in-product micro-surveys that will supercharge your product experience. Use micro-surveys to target the right users at the right time without making surveys annoying. +Formbricks provides a free and open source surveying platform. Gather feedback at every point in the user journey with beautiful in-app, website, link and email surveys. Build on top of Formbricks or leverage prebuilt data analysis capabilities. -**Try it out in the cloud at [formbricks.com](https://formbricks.com)** +**Try it out in the cloud at [formbricks.com](https://app.formbricks.com/auth/signup)** -## 💪 Mission: Make customer-centric decisions based on data. +## 💪 Mission: Empower your team, craft an irresistible experience. -Formbricks helps you apply best practices from data-driven work and experience management to make better business decisions. Ask users as they experience your product - and leverage a significantly higher conversion rate. Gather all insights you can - including partial submissions and build conviction for the next product decision. Better data, better business. +Formbricks is both a free and open source survey platform - and a privacy-first experience management platform. Use in-app, website, link and email surveys to gather user and customer insights at every point of their journey. Leverage Formbricks Insight Platform or build your own. Life's too short for mediocre UX. + +### Table of Contents + +- [Features](#features) + +- [Getting Started](#getting-started) + +- [Cloud Version](#cloud-version) + +- [Self-hosted Version](#self-hosted-version) + +- [Development](#development) + +- [Contribution](#contribution) + +- [Contact](#contact-us) + +- [License](#license) + +- [Security](#security) + + ### Features -- 📲 Create **in-product surveys** with our no code editor with multiple question types. +- 📲 Create **conversion-optimized surveys** with our no-code editor with several question types. + - 📚 Choose from a variety of best-practice **templates**. + - 👩🏻 Launch and **target your surveys to specific user groups** without changing your application code. + - 🔗 Create shareable **link surveys**. + - 👨‍👩‍👦 Invite your team members to **collaborate** on your surveys. -- 🔌 Integrate Formbricks with **Slack, Posthog, Zapier, n8n and more**. + +- 🔌 Integrate Formbricks with **Slack, Notion, Zapier, n8n and more**. + - 🔒 All **open source**, transparent and self-hostable. ### Built on Open Source - 💻 [Typescript](https://www.typescriptlang.org/) + - 🚀 [Next.js](https://nextjs.org/) + - ⚛️ [React](https://reactjs.org/) + - 🎨 [TailwindCSS](https://tailwindcss.com/) + - 📚 [Prisma](https://prisma.io/) + - 🔒 [Auth.js](https://authjs.dev/) + - 🧘‍♂️ [Zod](https://zod.dev/) + + ## 🚀 Getting started We've got several options depending on your need to help you quickly get started with Formbricks. + + ### ☁️ Cloud Version -Formbricks has a hosted cloud offering with a generous free plan to get you up and running as quickly as possible. To get started, please visit [formbricks.com](https://formbricks.com). +Formbricks has a hosted cloud offering with a generous free plan to get you up and running as quickly as possible. To get started, please visit [formbricks.com](https://app.formbricks.com/auth/signup). -### 🐳 Self-hosted version + + +### 🐳 Self-hosting Formbricks Formbricks is available Open-Source under AGPLv3 license. You can host Formbricks on your own servers using Docker without a subscription. @@ -89,7 +134,7 @@ If you opt for self-hosting Formbricks, here are a few options to consider: To get started with self-hosting with Docker, take a look at our [self-hosting docs](https://formbricks.com/docs/self-hosting/deployment). -#### Community managed One Click Hosting +#### Community-managed One Click Hosting ##### Railway @@ -97,6 +142,8 @@ You can deploy Formbricks on [Railway](https://railway.app) using the button bel [![Deploy on Railway](https://railway.app/button.svg)](https://railway.app/new/template/PPDzCd) + + ### 👨‍💻 Development #### Prerequisites @@ -104,7 +151,9 @@ You can deploy Formbricks on [Railway](https://railway.app) using the button bel Here is what you need to be able to run Formbricks: - [Node.js](https://nodejs.org/en) (Version: >=18.x) + - [Pnpm](https://pnpm.io/) + - [Docker](https://www.docker.com/) - to run PostgreSQL and MailHog #### Local Setup @@ -119,6 +168,8 @@ To get started locally, we've got a [guide to help you](https://formbricks.com/d [![Open in Gitpod](https://gitpod.io/button/open-in-gitpod.svg)](https://gitpod.io/#https://github.com/formbricks/formbricks) + + ## ✍️ Contribution We are very happy if you are interested in contributing to Formbricks 🤗 @@ -126,27 +177,39 @@ We are very happy if you are interested in contributing to Formbricks 🤗 Here are a few options: - Star this repo. + - Create issues every time you feel something is missing or goes wrong. -- Upvote issues with 👍 reaction so we know what's the demand for a particular issue to prioritize it within the roadmap. + +- Upvote issues with 👍 reaction so we know what the demand for a particular issue is to prioritize it within the roadmap. Please check out [our contribution guide](https://formbricks.com/docs/contributing/introduction) and our [list of open issues](https://github.com/formbricks/formbricks/issues) for more information. ## All Thanks To Our Contributors - - + + + + + + ## 📆 Contact us Let's have a chat about your survey needs and get you started. -Book us with Cal.com +Book us with Cal.com + + ## ⚖️ License Distributed under the AGPLv3 License. See [`LICENSE`](./LICENSE) for more information. + + ## 🔒 Security We take security very seriously. If you come across any security vulnerabilities, please disclose them by sending an email to security@formbricks.com. We appreciate your help in making our platform as secure as possible and are committed to working with you to resolve any issues quickly and efficiently. See [`SECURITY.md`](./SECURITY.md) for more information. + +

🔼 Back to top

diff --git a/apps/demo/.env.example b/apps/demo/.env.example index e6c657045e..90d5b7d8c8 100644 --- a/apps/demo/.env.example +++ b/apps/demo/.env.example @@ -2,4 +2,4 @@ NEXT_PUBLIC_FORMBRICKS_API_HOST=http://localhost:3000 NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID=YOUR_ENVIRONMENT_ID # Copy the environment ID for the URL of your Formbricks App and -# paste it above to connect your Formbricks App with the Demo App. +# paste it above to connect your Formbricks App with the Demo App. \ No newline at end of file diff --git a/apps/demo/package.json b/apps/demo/package.json index 460a938e41..d94d7dc7c1 100644 --- a/apps/demo/package.json +++ b/apps/demo/package.json @@ -12,8 +12,8 @@ }, "dependencies": { "@formbricks/js": "workspace:*", - "@heroicons/react": "^2.0.18", - "next": "13.5.5", + "@heroicons/react": "^2.1.1", + "next": "14.0.4", "react": "18.2.0", "react-dom": "18.2.0" }, diff --git a/apps/demo/pages/_app.tsx b/apps/demo/pages/_app.tsx index 08ab48b9e2..2ce0edb5ce 100644 --- a/apps/demo/pages/_app.tsx +++ b/apps/demo/pages/_app.tsx @@ -1,38 +1,9 @@ -import formbricks from "@formbricks/js"; import type { AppProps } from "next/app"; import Head from "next/head"; -import { useRouter } from "next/router"; -import { useEffect } from "react"; + import "../styles/globals.css"; -declare const window: any; - -if (typeof window !== "undefined") { - if (process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID && process.env.NEXT_PUBLIC_FORMBRICKS_API_HOST) { - formbricks.init({ - environmentId: process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID, - apiHost: process.env.NEXT_PUBLIC_FORMBRICKS_API_HOST, - debug: true, - }); - window.formbricks = formbricks; - } -} - export default function App({ Component, pageProps }: AppProps) { - const router = useRouter(); - - useEffect(() => { - // Connect next.js router to Formbricks - if (process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID && process.env.NEXT_PUBLIC_FORMBRICKS_API_HOST) { - const handleRouteChange = formbricks?.registerRouteChange; - router.events.on("routeChangeComplete", handleRouteChange); - - return () => { - router.events.off("routeChangeComplete", handleRouteChange); - }; - } - }, []); - return ( <> diff --git a/apps/demo/pages/_document.tsx b/apps/demo/pages/_document.tsx index ac43b6f24d..816404f321 100644 --- a/apps/demo/pages/_document.tsx +++ b/apps/demo/pages/_document.tsx @@ -1,4 +1,4 @@ -import { Html, Head, Main, NextScript } from "next/document"; +import { Head, Html, Main, NextScript } from "next/document"; export default function Document() { return ( diff --git a/apps/demo/pages/app/index.tsx b/apps/demo/pages/app/index.tsx index 6ead097ac2..10b9d8cca9 100644 --- a/apps/demo/pages/app/index.tsx +++ b/apps/demo/pages/app/index.tsx @@ -1,10 +1,16 @@ -import formbricks from "@formbricks/js"; import Image from "next/image"; +import { useRouter } from "next/router"; import { useEffect, useState } from "react"; + +import formbricks from "@formbricks/js"; + import fbsetup from "../../public/fb-setup.png"; +declare const window: any; + export default function AppPage({}) { const [darkMode, setDarkMode] = useState(false); + const router = useRouter(); useEffect(() => { if (darkMode) { @@ -14,8 +20,34 @@ export default function AppPage({}) { } }, [darkMode]); + useEffect(() => { + if (process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID && process.env.NEXT_PUBLIC_FORMBRICKS_API_HOST) { + const isUserId = window.location.href.includes("userId=true"); + const userId = isUserId ? "THIS-IS-A-VERY-LONG-USER-ID-FOR-TESTING" : undefined; + const attributes = isUserId ? { "Init Attribute 1": "eight", "Init Attribute 2": "two" } : undefined; + formbricks.init({ + environmentId: process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID, + apiHost: process.env.NEXT_PUBLIC_FORMBRICKS_API_HOST, + userId, + debug: true, + attributes, + }); + window.formbricks = formbricks; + } + + // Connect next.js router to Formbricks + if (process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID && process.env.NEXT_PUBLIC_FORMBRICKS_API_HOST) { + const handleRouteChange = formbricks?.registerRouteChange; + router.events.on("routeChangeComplete", handleRouteChange); + + return () => { + router.events.off("routeChangeComplete", handleRouteChange); + }; + } + }); + return ( -
+

@@ -29,7 +61,7 @@ export default function AppPage({}) {

@@ -42,7 +74,7 @@ export default function AppPage({}) {

fb setup -
+

You're connected with env:

@@ -204,25 +236,37 @@ export default function AppPage({}) {
-
- -
+ {router.query.userId === "true" ? ( +
+ +
+ ) : ( +
+ +
+ )}

- This button sets an external{" "} + This button activates/deactivates{" "} - user ID + user identification {" "} - to 'THIS-IS-A-VERY-LONG-USER-ID-FOR-TESTING' + with the userId 'THIS-IS-A-VERY-LONG-USER-ID-FOR-TESTING'

diff --git a/apps/demo/pages/signin/index.tsx b/apps/demo/pages/signin/index.tsx deleted file mode 100644 index b8b2d06665..0000000000 --- a/apps/demo/pages/signin/index.tsx +++ /dev/null @@ -1,152 +0,0 @@ -import formbricks from "@formbricks/js"; -import { useRouter } from "next/router"; -import { FormEvent } from "react"; - -export default function SiginPage() { - const router = useRouter(); - - const submitAction = (e: FormEvent) => { - e.preventDefault(); - if (process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID && process.env.NEXT_PUBLIC_FORMBRICKS_API_HOST) { - formbricks.setEmail("matti@example.com"); - formbricks.setUserId("123456"); - formbricks.setAttribute("Plan", "Premium"); - } - router.push("/app"); - }; - return ( -
-
-

- Sign in to your account -

-

- Or{" "} - - start your 14-day free trial - -

-
- -
-
-
-
- -
- -
-
- -
- -
- -
-
- -
-
- - -
- - -
- -
- -
-
- - -
-
- ); -} diff --git a/apps/demo/pages/test-nocode-app/index.tsx b/apps/demo/pages/test-nocode-app/index.tsx deleted file mode 100644 index 9e4baf53b8..0000000000 --- a/apps/demo/pages/test-nocode-app/index.tsx +++ /dev/null @@ -1,202 +0,0 @@ -import formbricks from "@formbricks/js"; -import Image from "next/image"; -import { useEffect, useState } from "react"; -import fbsetup from "../../public/fb-setup.png"; - -export default function AppPage({}) { - const [darkMode, setDarkMode] = useState(false); - - useEffect(() => { - if (darkMode) { - document.body.classList.add("dark"); - } else { - document.body.classList.remove("dark"); - } - }, [darkMode]); - - return ( -
-
-
-

- Formbricks In-product Survey Demo App -

-

- This app helps you test your in-app surveys. You can create and test user actions, create and - update user attributes, etc. -

-
- -
- -
-
-
-

1. Setup .env

-

- Copy the environment ID of your Formbricks app to the env variable in demo/.env -

- fb setup - -
-

You're connected with env:

-
- - {process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID} - - - - - -
-
-
-
-

2. Widget Logs

-

- Look at the logs to understand how the widget works.{" "} - Open your browser console to see the logs. -

- {/*
- -
*/} -
-
- -
-
-

- Reset person / pull data from Formbricks app -

-

- On formbricks.reset() a few things happen: New person is created and{" "} - surveys & no-code actions are pulled from Formbricks:. -

- -

- If you made a change in Formbricks app and it does not seem to work, hit 'Reset' and - try again. -

-
-
-
- -
-
-

Inner Text only

-
-
- -
-
- -
-
-

Inner Text + Css ID

-
-
- -
-
- -
-
-

Inner Text + CSS Class

-
-
- -
-
- -
-
-

ID + Class

-
-
- -
-
- -
-
-

ID only

-
-
- -
-
- -
-
-

Class only

-
-
- -
-
- -
-
-

Class + Class

-
-
-
-
-
- ); -} diff --git a/apps/demo/styles/globals.css b/apps/demo/styles/globals.css index b5c61c9567..640abab484 100644 --- a/apps/demo/styles/globals.css +++ b/apps/demo/styles/globals.css @@ -1,3 +1,26 @@ @tailwind base; @tailwind components; @tailwind utilities; + +/* Example on overriding packages/js colors */ +.dark { + --fb-brand-color: red; + --fb-brand-text-color: white; + --fb-border-color: green; + --fb-border-color-highlight: var(--slate-500); + --fb-focus-color: red; + --fb-heading-color: yellow; + --fb-subheading-color: green; + --fb-info-text-color: orange; + --fb-signature-text-color: blue; + --fb-survey-background-color: black; + --fb-accent-background-color: rgb(13, 13, 12); + --fb-accent-background-color-selected: red; + --fb-placeholder-color: white; + --fb-shadow-color: yellow; + --fb-rating-fill: var(--yellow-300); + --fb-rating-hover: var(--yellow-500); + --fb-back-btn-border: currentColor; + --fb-submit-btn-border: transparent; + --fb-rating-selected: black; +} diff --git a/apps/formbricks-com/app/docs/actions/code/page.mdx b/apps/formbricks-com/app/docs/actions/code/page.mdx index 08187966f0..058988349d 100644 --- a/apps/formbricks-com/app/docs/actions/code/page.mdx +++ b/apps/formbricks-com/app/docs/actions/code/page.mdx @@ -1,14 +1,17 @@ -export const meta = { +export const metadata = { title: "Implementing Code Actions in Formbricks | Real-time User Action Tracking", description: - "Dive into the world of Formbricks' code actions. Learn how to seamlessly integrate formbricks.track() method into your codebase, enabling real-time tracking of user actions like button clicks, visiting a specfic URL. Up your survey game with precise and exact triggers.", + "Dive into the world of Formbricks' code actions. Learn how to seamlessly integrate formbricks.track() method into your codebase, enabling real-time tracking of user actions like button clicks, visiting a specific URL. Up your survey game with precise and exact triggers.", }; #### Actions # Code Actions -Actions can also be set in the code base. You can fire an action using `formbricks.track()` +Actions can also be set in the codebase to trigger surveys. Please add the code action first in the Formbricks web interface to be able to configure your surveys to use this action. + +After that you can fire an action using `formbricks.track()` + @@ -31,4 +34,4 @@ return ; ``` - \ No newline at end of file + diff --git a/apps/formbricks-com/app/docs/actions/no-code/page.mdx b/apps/formbricks-com/app/docs/actions/no-code/page.mdx index d0865cf06b..4f08875491 100644 --- a/apps/formbricks-com/app/docs/actions/no-code/page.mdx +++ b/apps/formbricks-com/app/docs/actions/no-code/page.mdx @@ -1,4 +1,4 @@ -export const meta = { +export const metadata = { title: "Implementing No-Code Actions in Formbricks | Real-time User Action Tracking", description: "Discover the power of Formbricks' No-Code Actions. Easily set up triggers based on Page URL, innerText, and CSS Selectors without touching a line of code. Inccrease user engagement and get insights at precise moments in the user journey.", diff --git a/apps/formbricks-com/app/docs/actions/why/page.mdx b/apps/formbricks-com/app/docs/actions/why/page.mdx index 06cc11c687..85abbced7f 100644 --- a/apps/formbricks-com/app/docs/actions/why/page.mdx +++ b/apps/formbricks-com/app/docs/actions/why/page.mdx @@ -1,4 +1,4 @@ -export const meta = { +export const metadata = { title: "Using Actions in Formbricks | Fine-tuning User Moments", description: "Dive deep into how actions in Formbricks help products and teams to engage users at precise moments in their journey. Discover the power of actions, from coding to no-code setups, to refine user targeting and generate richer, more detailed user insights.", diff --git a/apps/formbricks-com/app/docs/api/client/actions/page.mdx b/apps/formbricks-com/app/docs/api/client/actions/page.mdx new file mode 100644 index 0000000000..1fcd75c1c9 --- /dev/null +++ b/apps/formbricks-com/app/docs/api/client/actions/page.mdx @@ -0,0 +1,88 @@ +import { Fence } from "@/components/shared/Fence"; + +export const metadata = { + title: "Formbricks Responses API Documentation - Manage Your Survey Data Seamlessly", + description: + "Unlock the full potential of Formbricks' Client Actions API. Create Actions right from the API.", +}; + +#### Client API + +# Actions API + +The Public Client API is designed for the JavaScript SDK and does not require authentication. It's primarily used for creating persons, sessions, and responses within the Formbricks platform. This API is ideal for client-side interactions, as it doesn't expose sensitive information. + +This API can be used to: +- [Add Action for User](#add-action-for-user) + + +--- + +## Add Action for User {{ tag: 'POST', label: '/api/v1/client//actions' }} + +Adds an Actions for a given User by their User ID + + + + + ### Mandatory Body Fields + + + + The id of the user for whom the action is being created. + + + The name of the Action being created. + + + + + + + + + ```bash {{ title: 'cURL' }} + curl --location --request POST 'https://app.formbricks.com/api/v1/client//actions' \ + --data-raw '{ + "userId": "1", + "name": "new_action_v2", + "properties":{} + + }' + ``` + + ```json {{ title: 'Example Request Body' }} + { + "userId": "1", + "name": "new_action_v3", + "properties":{} + } + ``` + + + + + + ```json {{ title: '200 Success' }} + { + "data": {} + } + ``` + + ```json {{ title: '400 Bad Request' }} + { + "code": "bad_request", + "message": "Fields are missing or incorrectly formatted", + "details": { + "name": "Required" + } + } + ``` + + + + + + +--- + diff --git a/apps/formbricks-com/app/docs/api/client/displays/page.mdx b/apps/formbricks-com/app/docs/api/client/displays/page.mdx index fe7c7aa916..fd14e1084f 100644 --- a/apps/formbricks-com/app/docs/api/client/displays/page.mdx +++ b/apps/formbricks-com/app/docs/api/client/displays/page.mdx @@ -1,9 +1,9 @@ import { Fence } from "@/components/shared/Fence"; -export const meta = { +export const metadata = { title: "Formbricks Public Client API Guide: Manage Survey Displays & Responses", description: - "Dive deep into Formbricks' Public Client API designed for customisation. This comprehensive guide provides detailed instructions on how to mark surveys as displayed as well as responded for individual persons, ensuring seamless client-side interactions without compromising data security.", + "Dive deep into Formbricks' Public Client API designed for customisation. This comprehensive guide provides detailed instructions on how to mark create and update survey displays for users.", }; #### Client API @@ -13,17 +13,17 @@ export const meta = { The Public Client API is designed for the JavaScript SDK and does not require authentication. It's primarily used for creating persons, sessions, and responses within the Formbricks platform. This API is ideal for client-side interactions, as it doesn't expose sensitive information. This set of API can be used to -- [Mark Survey as Displayed](#mark-survey-as-displayed-for-person) -- [Mark Survey as Responded](#mark-survey-as-responded-for-person) +- [Create Display](#create-display) +- [Update Display](#update-display) --- -## Mark Survey as Displayed for Person {{ tag: 'POST', label: '/api/v1/client/diplays' }} +## Create Display {{ tag: 'POST', label: '/api/v1/client//diplays' }} - Mark a Survey as seen for a Person provided valid SurveyId and PersonId. + Create Display of survey for a user ### Mandatory Request Body JSON Keys @@ -32,25 +32,30 @@ This set of API can be used to + ### Optional Request Body JSON Keys - - Person ID for whom mark a survey as viewed + + Already existing user's ID to mark as viewed for a survey + + + Already existing response's ID to link with this new Display + - + ```bash {{ title: 'cURL' }} curl -X POST \ 'https://app.formbricks.com/api/v1/client/displays' \ -H 'Content-Type: application/json' \ -d '{ - "surveyId": "", - "personId": "" - }' + "surveyId":"", + "userId":"" + }' ``` @@ -60,31 +65,68 @@ This set of API can be used to ```json {{title:'200 Success'}} { "data": { - "id": "clm4qiygr00uqs60h5f5ola5h", - "createdAt": "2023-09-04T10:24:36.603Z", - "updatedAt": "2023-09-04T10:24:36.603Z", - "surveyId": "", - "person": { - "id": "", - "attributes": { - "userId": "CYO600", - "email": "wei@google.com", - "Name": "Wei Zhu", - "Role": "Manager", - "Company": "Google", - "Experience": "2 years", - "Usage Frequency": "Daily", - "Company Size": "2401 employees", - "Product Satisfaction Score": "4", - "Recommendation Likelihood": "3" - }, - "createdAt": "2023-08-08T18:05:01.483Z", - "updatedAt": "2023-08-08T18:05:01.483Z" - }, - "status": "seen" + "id": "clphzz6oo00083zdmc7e0nwzi" + } + } + ``` + + ```json {{ title: '400 Bad Request' }} + { + "code": "bad_request", + "message": "Fields are missing or incorrectly formatted", + "details": { + "surveyId": "Required" } } ``` + + + + + +--- + +## Update Display {{ tag: 'PUT', label: '/api/v1/client//diplays/' }} + + + + + Update a display by it's ID + + ### Optional Request Body JSON Keys + + + Already existing user's ID to mark as viewed for a survey + + + Already existing response's ID to link with this new Display + + + + + + + + + + ```bash {{ title: 'cURL' }} + curl -X POST \ + 'https://app.formbricks.com/api/v1/client//displays/' \ + -H 'Content-Type: application/json' \ + -d '{ + "userId":"" + }' + ``` + + + + + + ```json {{title:'200 Success'}} + { + "data": {} + } + ``` ```json {{ title: '400 Bad Request' }} { @@ -101,68 +143,3 @@ This set of API can be used to --- - -## Mark Survey as Responded for Person {{ tag: 'POST', label: '/api/v1/client/diplays/[displayId]/responded' }} - - - - - Mark a Displayed Survey as responded for a Person. - - - - - - - ```bash {{ title: 'cURL' }} - curl -X POST \ - --location \ - 'https://app.formbricks.com/api/v1/client/displays//responded' - ``` - - - - - - ```json {{title:'200 Success'}} - { - "data": { - "id": "", - "createdAt": "2023-09-04T10:24:36.603Z", - "updatedAt": "2023-09-04T10:33:56.978Z", - "surveyId": "", - "person": { - "id": "", - "attributes": { - "userId": "CYO600", - "email": "wei@google.com", - "Name": "Wei Zhu", - "Role": "Manager", - "Company": "Google", - "Experience": "2 years", - "Usage Frequency": "Daily", - "Company Size": "2401 employees", - "Product Satisfaction Score": "4", - "Recommendation Likelihood": "3" - }, - "createdAt": "2023-08-08T18:05:01.483Z", - "updatedAt": "2023-08-08T18:05:01.483Z" - }, - "status": "responded" - } - } - ``` - - ```json {{ title: '500 Internal Server Error' }} - { - "code": "internal_server_error", - "message": "Database operation failed", - "details": {} - } - ``` - - - - - ---- diff --git a/apps/formbricks-com/app/docs/api/client/overview/page.mdx b/apps/formbricks-com/app/docs/api/client/overview/page.mdx index eb8de73d1e..8399456133 100644 --- a/apps/formbricks-com/app/docs/api/client/overview/page.mdx +++ b/apps/formbricks-com/app/docs/api/client/overview/page.mdx @@ -1,4 +1,4 @@ -export const meta = { +export const metadata = { title: "Formbricks API Overview: Public Client & Management API Breakdown", description: "Get a detailed understanding of Formbricks' dual API offerings: the unauthenticated Public Client API optimized for client-side tasks and the secured Management API for advanced account operations. Choose the perfect fit for your integration needs and ensure robust data handling", @@ -10,13 +10,15 @@ export const meta = { Formbricks offers two types of APIs: the **Public Client API** and the **Management API**. Each API serves a different purpose, has different authentication requirements, and provides access to different data and settings. -Checkout the [API Key Setup](/docs/api/api-key-setup) - to generate, store, or delete API Keys. +Checkout the [API Key Setup](/docs/api/management/api-key-setup) - to generate, store, or delete API Keys. ## Public Client API The Public Client API is designed for the JavaScript SDK and does not require authentication. It's primarily used for creating persons, sessions, and responses within the Formbricks platform. This API is ideal for client-side interactions, as it doesn't expose sensitive information. +- [Actions API](/docs/api/client/actions) - Create actions for a person - [Displays API](/docs/api/client/displays) - Mark Survey as Displayed or Responded for a Person +- [People API](/docs/api/client/people) - Create & update people (e.g. attributes) - [Responses API](/docs/api/client/responses) - Create & update responses for a survey ## Management API @@ -27,7 +29,7 @@ The Management API provides access to all data and settings that are visible in API requests made to the Management API are authorized using a personal API key. This key grants the same rights and access as if you were logged in at formbricks.com. It's essential to keep your API key secure and not share it with others. -To generate, store, or delete an API key, follow the instructions provided on the following page [API Key](/docs/api/api-key-setup). +To generate, store, or delete an API key, follow the instructions provided on the following page [API Key](/docs/api/management/api-key-setup). - [Action Class API](/docs/api/management/action-classes) - Create, Update, and Delete Action Classes - [Attribute Class API](/docs/api/management/attribute-classes) - Create, Update, and Delete Attribute Classes diff --git a/apps/formbricks-com/app/docs/api/client/people/page.mdx b/apps/formbricks-com/app/docs/api/client/people/page.mdx new file mode 100644 index 0000000000..d4b85735b5 --- /dev/null +++ b/apps/formbricks-com/app/docs/api/client/people/page.mdx @@ -0,0 +1,130 @@ +import { Fence } from "@/components/shared/Fence"; + +export const metadata = { + title: "Formbricks Public Client API Guide: Manage Users", + description: + "Dive deep into Formbricks' Public Client API designed for customisation. This comprehensive guide provides detailed instructions on creating and updating users to help in user identification.", +}; + +#### Client API + +# People API + +The Public Client API is designed for the JavaScript SDK and does not require authentication. It's primarily used for creating persons, sessions, and responses within the Formbricks platform. This API is ideal for client-side interactions, as it doesn't expose sensitive information. + +This set of API can be used to +- [Create Person](#create-person) +- [Update Person](#update-person) + +--- + +## Create Person {{ tag: 'POST', label: '/api/v1/client//people' }} + + + + + Create User with your own User ID + + ### Mandatory Request Body JSON Keys + + + User ID which you would like to identify the person with + + + + + + + + + ```bash {{ title: 'cURL' }} + curl -X POST \ + 'https://app.formbricks.com/api/v1/client//people' \ + -H 'Content-Type: application/json' \ + -d '{ + "userId":"docs_user" + }' + ``` + + + + + + ```json {{title:'200 Success'}} + { + "data": { + "userId": "docs_user" + } + } + ``` + + ```json {{ title: '400 Bad Request' }} + { + "code": "bad_request", + "message": "Fields are missing or incorrectly formatted", + "details": { + "surveyId": "Required" + } + } + ``` + + + + + +--- + +## Update Person {{ tag: 'POST', label: '/api/v1/client//people/' }} + + + + + Update Person by their User ID + + ### Mandatory Request Body JSON Keys + + + Key Value pairs of attributes to add to the user + + + + + + + + + ```bash {{ title: 'cURL' }} + curl -X POST \ + --location \ + 'https://app.formbricks.com/api/v1/client//people/' + -H 'Content-Type: application/json' \ + -d '{ + "attributes":{ + "welcome_to":"formbricks" + } + }' + ``` + + + + + + ```json {{title:'200 Success'}} + { + "data": {} + } + ``` + + ```json {{ title: '500 Internal Server Error' }} + { + "code": "internal_server_error", + "message": "Database operation failed", + "details": {} + } + ``` + + + + + +--- diff --git a/apps/formbricks-com/app/docs/api/client/responses/page.mdx b/apps/formbricks-com/app/docs/api/client/responses/page.mdx index 7ff444056a..b2099d95fb 100644 --- a/apps/formbricks-com/app/docs/api/client/responses/page.mdx +++ b/apps/formbricks-com/app/docs/api/client/responses/page.mdx @@ -1,20 +1,24 @@ import { Fence } from "@/components/shared/Fence"; -export const meta = { +export const metadata = { title: "Formbricks Responses API Documentation - Manage Your Survey Data Seamlessly", description: "Unlock the full potential of Formbricks' Responses API. From fetching to updating survey responses, our comprehensive guide helps you integrate and manage survey data efficiently without compromising security. Ideal for client-side interactions.", }; -#### Management API +#### Client API # Responses API The Public Client API is designed for the JavaScript SDK and does not require authentication. It's primarily used for creating persons, sessions, and responses within the Formbricks platform. This API is ideal for client-side interactions, as it doesn't expose sensitive information. +This set of API can be used to +- [Create Response](#create-response) +- [Update Response](#update-response) + --- -## Create a response {{ tag: 'POST', label: '/api/v1/client/responses' }} +## Create Response {{ tag: 'POST', label: '/api/v1/client//responses' }} Add a new response to a survey. @@ -39,8 +43,8 @@ Add a new response to a survey. ### Optional Body Fields - - Internal Formbricks id to identify the user sending the response + + Pre-existing User ID to identify the user sending the response @@ -49,20 +53,20 @@ Add a new response to a survey. | field name | required | default | description | | ---------- | -------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | | data | yes | - | The response data object (answers to the survey). In this object the key is the questionId, the value the answer of the user to this question. | -| personId | no | - | The person this response is connected to. | +| userId | no | - | The person this response is connected to. | | surveyId | yes | - | The survey this response is connected to. | | finished | yes | false | Mark a response as complete to be able to filter accordingly. | - + ```bash {{ title: 'cURL' }} - curl --location --request POST 'https://app.formbricks.com/api/v1/client/responses' \ + curl --location --request POST 'https://app.formbricks.com/api/v1/client//responses' \ --data-raw '{ - "surveyId":"clfqz1esd0000yzah51trddn8", - "personId": "clfqjny0v000ayzgsycx54a2c", + "surveyId":"cloqzeuu70000z8khcirufo60", + "userId": "1", "finished": true, "data": { "clfqjny0v0003yzgscnog1j9i": 10, @@ -73,8 +77,8 @@ Add a new response to a survey. ```json {{ title: 'Example Request Body' }} { - "personId": "clfqjny0v000ayzgsycx54a2c", - "surveyId": "clfqz1esd0000yzah51trddn8", + "userId": "1", + "surveyId": "cloqzeuu70000z8khcirufo60", "finished": true, "data": { "clfqjny0v0003yzgscnog1j9i": 10, @@ -90,19 +94,7 @@ Add a new response to a survey. ```json {{ title: '200 Success' }} { "data": { - "id": "clisyqeoi000219t52m5gopke", - "surveyId": "clfqz1esd0000yzah51trddn8", - "finished": true, - "person": { - "id": "clfqjny0v000ayzgsycx54a2c", - "attributes": { - "email": "me@johndoe.com" - } - }, - "data": { - "clfqjny0v0003yzgscnog1j9i": 10, - "clfqjtn8n0070yzgs6jgx9rog": "I love Formbricks" - } + "id": "clp84xdld0002px36fkgue5ka", } } ``` @@ -124,7 +116,7 @@ Add a new response to a survey. --- -## Update a response {{ tag: 'POST', label: '/api/v1/client/responses/' }} +## Update Response {{ tag: 'PUT', label: '/api/v1/client//responses/' }} Update an existing response in a survey. @@ -134,6 +126,9 @@ Update an existing response in a survey. ### Mandatory Body Fields + + Marks whether the response is complete or not. + The data of the response as JSON object (key: questionId, value: answer). @@ -149,27 +144,25 @@ Update an existing response in a survey. - + ```bash {{ title: 'cURL' }} - curl --location --request POST 'https://app.formbricks.com/api/v1/client/responses/' \ + curl --location --request PUT 'https://app.formbricks.com/api/v1/client//responses/' \ --data-raw '{ - "personId": "clfqjny0v000ayzgsycx54a2c", - "surveyId": "clfqz1esd0000yzah51trddn8", - "finished": true, - "data": { - "clggpvpvu0009n40g8ikawby8": 5, + "finished":false, + "data": { + "clfqjny0v0003yzgscnog1j9i": 10, + "clfqjtn8n0070yzgs6jgx9rog": "I love Formbricks" } }' ``` ```json {{ title: 'Example Request Body' }} { - "personId": "clfqjny0v000ayzgsycx54a2c", - "surveyId": "clfqz1esd0000yzah51trddn8", - "finished": true, - "data": { - "clggpvpvu0009n40g8ikawby8": 5, + "finished":false, + "data": { + "clfqjny0v0003yzgscnog1j9i": 10, + "clfqjtn8n0070yzgs6jgx9rog": "I love Formbricks" } } ``` @@ -180,22 +173,7 @@ Update an existing response in a survey. ```json {{ title: '200 Success' }} { - "data": { - "id": "clisyqeoi000219t52m5gopke", - "surveyId": "clfqz1esd0000yzah51trddn8", - "finished": true, - "person": { - "id": "clfqjny0v000ayzgsycx54a2c", - "attributes": { - "email": "me@johndoe.com" - } - }, - "data": { - "clfqjny0v0003yzgscnog1j9i": 10, - "clfqjtn8n0070yzgs6jgx9rog": "I love Formbricks", - "clggpvpvu0009n40g8ikawby8": 5 - } - } + "data": {} } ``` diff --git a/apps/formbricks-com/app/docs/api/management/action-classes/page.mdx b/apps/formbricks-com/app/docs/api/management/action-classes/page.mdx index 85a5505b3f..d7be643d30 100644 --- a/apps/formbricks-com/app/docs/api/management/action-classes/page.mdx +++ b/apps/formbricks-com/app/docs/api/management/action-classes/page.mdx @@ -1,10 +1,6 @@ import { Fence } from "@/components/shared/Fence"; - -export const meta = { - title: "Formbricks People API: Fetch or Create Person Overview", - description: - "Dive into Formbricks' People API within the Public Client API suite, designed to work without authentication requirements. Seamlessly fetch or create a person by their userId and environmentId, optimizing client-side interactions while maintaining data privacy.", -}; +import {generateManagementApiMetadata} from "@/lib/utils" +export const metadata = generateManagementApiMetadata("Action Class",["Fetch","Create","Delete"]) #### Management API diff --git a/apps/formbricks-com/app/docs/api/management/api-key-setup/page.mdx b/apps/formbricks-com/app/docs/api/management/api-key-setup/page.mdx index bdd4f9962e..f03d46371a 100644 --- a/apps/formbricks-com/app/docs/api/management/api-key-setup/page.mdx +++ b/apps/formbricks-com/app/docs/api/management/api-key-setup/page.mdx @@ -3,7 +3,7 @@ import Image from "next/image"; import AddApiKey from "./add-api-key.webp"; import ApiKeySecret from "./api-key-secret.webp"; -export const meta = { +export const metadata = { title: "Formbricks API Key: Setup and Testing", description: "This guide provides step-by-step instructions to generate, store, and delete API keys, ensuring safe and authenticated access to your Formbricks account.", diff --git a/apps/formbricks-com/app/docs/api/management/attribute-classes/page.mdx b/apps/formbricks-com/app/docs/api/management/attribute-classes/page.mdx index c02f5bdabd..009376f10a 100644 --- a/apps/formbricks-com/app/docs/api/management/attribute-classes/page.mdx +++ b/apps/formbricks-com/app/docs/api/management/attribute-classes/page.mdx @@ -1,10 +1,7 @@ import { Fence } from "@/components/shared/Fence"; +import {generateManagementApiMetadata} from "@/lib/utils" -export const meta = { - title: "Formbricks People API: Fetch or Create Person Overview", - description: - "Dive into Formbricks' People API within the Public Client API suite, designed to work without authentication requirements. Seamlessly fetch or create a person by their userId and environmentId, optimizing client-side interAttributes while maintaining data privacy.", -}; +export const metadata = generateManagementApiMetadata("Attribute Class",["Fetch","Create","Delete"]) #### Management API diff --git a/apps/formbricks-com/app/docs/api/management/me/page.mdx b/apps/formbricks-com/app/docs/api/management/me/page.mdx index 147f8f58e9..c5ba911036 100644 --- a/apps/formbricks-com/app/docs/api/management/me/page.mdx +++ b/apps/formbricks-com/app/docs/api/management/me/page.mdx @@ -1,9 +1,9 @@ import { Fence } from "@/components/shared/Fence"; -export const meta = { - title: "Formbricks People API: Fetch or Create Person Overview", +export const metadata = { + title: "Formbricks Me API: Fetch your environment details", description: - "Dive into Formbricks' People API within the Public Client API suite, designed to work without authentication requirements. Seamlessly fetch or create a person by their userId and environmentId, optimizing client-side interactions while maintaining data privacy.", + "Dive into Formbricks' Me API within the Public Client API suite. Seamlessly fetch your own current environment details.", }; #### Management API diff --git a/apps/formbricks-com/app/docs/api/management/people/page.mdx b/apps/formbricks-com/app/docs/api/management/people/page.mdx index 569a401ebd..14856ba9fa 100644 --- a/apps/formbricks-com/app/docs/api/management/people/page.mdx +++ b/apps/formbricks-com/app/docs/api/management/people/page.mdx @@ -1,10 +1,8 @@ import { Fence } from "@/components/shared/Fence"; +import {generateManagementApiMetadata} from "@/lib/utils" + +export const metadata = generateManagementApiMetadata("People",["Fetch","Delete"]) -export const meta = { - title: "Formbricks People API: Fetch or Create Person Overview", - description: - "Dive into Formbricks' People API within the Public Client API suite, designed to work without authentication requirements. Seamlessly fetch or create a person by their userId and environmentId, optimizing client-side interactions while maintaining data privacy.", -}; #### Management API diff --git a/apps/formbricks-com/app/docs/api/management/responses/page.mdx b/apps/formbricks-com/app/docs/api/management/responses/page.mdx index eb564b7c2b..bd2c389c5f 100644 --- a/apps/formbricks-com/app/docs/api/management/responses/page.mdx +++ b/apps/formbricks-com/app/docs/api/management/responses/page.mdx @@ -1,10 +1,7 @@ import { Fence } from "@/components/shared/Fence"; +import {generateManagementApiMetadata} from "@/lib/utils" -export const meta = { - title: "Formbricks Responses API Documentation - Manage Your Survey Data Seamlessly", - description: - "Unlock the full potential of Formbricks' Responses API. From fetching to updating survey responses, our comprehensive guide helps you integrate and manage survey data efficiently without compromising security. Ideal for client-side interactions.", -}; +export const metadata = generateManagementApiMetadata("Responses",["Fetch","Delete"]) #### Management API @@ -221,7 +218,7 @@ This set of API can be used to ```bash {{ title: 'cURL' }} - curl -X DELETE https://app.formbricks.com/api/v1/management/resposnes/ \ + curl -X DELETE https://app.formbricks.com/api/v1/management/responses/ \ --header 'x-api-key: ' ``` diff --git a/apps/formbricks-com/app/docs/api/management/surveys/page.mdx b/apps/formbricks-com/app/docs/api/management/surveys/page.mdx index 5e12ae6b15..00afe9edf9 100644 --- a/apps/formbricks-com/app/docs/api/management/surveys/page.mdx +++ b/apps/formbricks-com/app/docs/api/management/surveys/page.mdx @@ -1,10 +1,7 @@ import { Fence } from "@/components/shared/Fence"; +import {generateManagementApiMetadata} from "@/lib/utils" -export const meta = { - title: "Formbricks Surveys API Documentation - How to Retrieve All Surveys", - description: - "Explore the comprehensive guide to the Formbricks Surveys API. Learn how to effectively retrieve all the surveys in your environment with the necessary headers and API key setup. Includes sample request and response formats.", -}; +export const metadata = generateManagementApiMetadata("Surveys",["Fetch","Create","Update","Delete"]) #### Management API @@ -14,6 +11,7 @@ This set of API can be used to - [List All Surveys](#list-all-surveys) - [Get Survey](#get-survey-by-id) - [Create Survey](#create-survey) +- [Update Survey](#update-survey-by-id) - [Delete Survey](#delete-survey-by-id) You will need an API Key to interact with these APIs. @@ -146,7 +144,7 @@ This set of API can be used to { "id": "lkjaxb73ulydzeumhd51sx9g", "type": "openText", - "headline": "What is the main benefit your receive from My Product?", + "headline": "What is the main benefit you receive from My Product?", "required": true }, { @@ -412,7 +410,7 @@ This set of API can be used to ```bash {{ title: 'cURL' }} - curl -X DELETE \ + curl -X POST \ 'https://app.formbricks.com/api/v1/management/surveys' \ --header \ 'x-api-key: ' @@ -472,6 +470,121 @@ This set of API can be used to --- +## Update Survey by ID {{ tag: 'PUT', label: '/api/v1/management/surveys/' }} + + + + + Update a survey by its ID + + ### Mandatory Headers + + + + Your Formbricks API key. + + + + ### Body + + + ```json {{ title: 'cURL' }} + { + "name": "My renamed Survey", + "redirectUrl":"https://formbricks.com", + "type":"web" + } + ``` + + + + + + + + + ```bash {{ title: 'cURL' }} + curl -X POST https://app.formbricks.com/api/v1/management/surveys/ \ + --header 'Content-Type: application/json' \ + --header 'x-api-key: ' \ + -d '{"name": "My renamed Survey"}' + ``` + + + + + + ```json {{title:'200 Success'}} + { + "data": { + "id": "cloqzeuu70000z8khcirufo60", + "createdAt": "2023-11-09T09:23:42.367Z", + "updatedAt": "2023-11-09T09:23:42.367Z", + "name": "My renamed Survey", + "redirectUrl": null, + "type": "link", + "environmentId": "clonzr6vc0009z8md7y06hipl", + "status": "inProgress", + "welcomeCard": { + "html": "Thanks for providing your feedback - let's go!", + "enabled": false, + "headline": "Welcome!", + "timeToFinish": false + }, + "questions": [ + { + "id": "l9rwn5nbk48y44tvnyyjcvca", + "type": "openText", + "headline": "Why did you leave the platform?", + "required": true, + "inputType": "text" + } + ], + "thankYouCard": { + "enabled": true, + "headline": "Thank you!", + "subheader": "We appreciate your feedback." + }, + "hiddenFields": { + "enabled": true, + "fieldIds": [] + }, + "displayOption": "displayOnce", + "recontactDays": null, + "autoClose": null, + "delay": 0, + "autoComplete": 50, + "closeOnDate": null, + "surveyClosedMessage": null, + "productOverwrites": null, + "singleUse": { + "enabled": false, + "isEncrypted": true + }, + "verifyEmail": null, + "pin": null, + "triggers": [], + "attributeFilters": [] + } + } + ``` + + ```json {{ title: '401 Not Authenticated' }} + { + "code": "not_authenticated", + "message": "Not authenticated", + "details": { + "x-Api-Key": "Header not provided or API Key invalid" + } + } + ``` + + + + + +--- + ## Delete Survey by ID {{ tag: 'DELETE', label: '/api/v1/management/surveys/' }} diff --git a/apps/formbricks-com/app/docs/api/management/webhooks/page.mdx b/apps/formbricks-com/app/docs/api/management/webhooks/page.mdx index 9ace9def1c..a4efd78cf9 100644 --- a/apps/formbricks-com/app/docs/api/management/webhooks/page.mdx +++ b/apps/formbricks-com/app/docs/api/management/webhooks/page.mdx @@ -1,8 +1,6 @@ -export const meta = { - title: "Formbricks Webhook API Documentation - List, Retrieve, Create, and Delete Webhooks", - description: - "Explore the comprehensive guide to the Formbricks Webhooks API. This is all you need to interact and play with the Formbricks Webhooks and integrate them into any third party app of your choice", -}; +import {generateManagementApiMetadata} from "@/lib/utils" + +export const metadata = generateManagementApiMetadata("Webhook",["Fetch","Create","Delete"]) #### Management API @@ -20,7 +18,7 @@ This set of API can be used to - [Create Webhook](#create-webhook) - [Delete Webhook](#delete-webhook-by-id) -And the detailed Webhook Paylod is elaborated [here](#webhook-payload). +And the detailed Webhook Payload is elaborated [here](#webhook-payload). These APIs are designed to facilitate seamless integration of Formbricks with third-party systems. By making use of our webhook API, you can automate the process of sending data to these systems whenever significant events occur within your Formbricks environment. diff --git a/apps/formbricks-com/app/docs/attributes/custom-attributes/page.mdx b/apps/formbricks-com/app/docs/attributes/custom-attributes/page.mdx index 8a81667446..8d18ae1629 100644 --- a/apps/formbricks-com/app/docs/attributes/custom-attributes/page.mdx +++ b/apps/formbricks-com/app/docs/attributes/custom-attributes/page.mdx @@ -1,4 +1,4 @@ -export const meta = { +export const metadata = { title: "Guide for Setting Custom Attributes | Formbricks Documentation", description: "Learn how to set attributes in code using setAttribute function. Enhance user segmentation, target surveys effectively, and gather valuable insights for better decisions. Easily send user-specific details for better survey segmentation and gain deeper insights.", @@ -10,9 +10,31 @@ export const meta = { One way to send attributes to Formbricks is in your code. In Formbricks, there are two special attributes for [user identification](/docs/attributes/identify-users)(user ID & email) and custom attributes. An example: -## Setting Custom User Attributes +## Setting during Initialization + +It's recommended to set custom user attributes directly during the initialization of Formbricks for better user identification. + + + + +```javascript +formbricks.init({ + environmentId: "", + apiHost: "", + userId: "", + attributes: { + plan: "free", + }, +}); +``` + + + + +## Setting independently + +You can use the setAttribute function to set any custom attribute for the user (e.g. name, plan, etc.) anywhere in the user journey. Formbricks maintains a state of the current user inside the browser and makes sure attributes aren't sent to the backend twice. -You can use the setAttribute function to set any custom attribute for the user (e.g. name, plan, etc.): diff --git a/apps/formbricks-com/app/docs/attributes/identify-users/page.mdx b/apps/formbricks-com/app/docs/attributes/identify-users/page.mdx index aed40904da..9ede6ff700 100644 --- a/apps/formbricks-com/app/docs/attributes/identify-users/page.mdx +++ b/apps/formbricks-com/app/docs/attributes/identify-users/page.mdx @@ -1,4 +1,4 @@ -export const meta = { +export const metadata = { title: "User Identification in Formbricks | Enhancing Survey Feedback", description: "A comprehensive guide on identifying users in Formbricks without compromising privacy. Learn how to set User ID, email, and custom attributes to optimize survey targeting, recontact users, and control survey intervals, all while respecting user anonymity.", @@ -10,23 +10,52 @@ export const meta = { At Formbricks, we value user privacy. By default, Formbricks doesn't collect or store any personal information from your users. However, we understand that it can be helpful for you to know which user submitted the feedback and also functionality like recontacting users and controlling the waiting period between surveys requires identifying the users. That's why we provide a way for you to share existing user data from your app, so you can view it in our dashboard. -Once the Formbricks widget is loaded on your web app, our SDK exposes methods for identifying user attributes. Let's set it up! +If you would like to use the User Identification feature of Formbricks, target surveys to specific user segments and see more information about the user who responded to a survey, you can identify users by setting a User ID, email, and custom attributes. This guide will walk you through how to do that. ## Setting User ID -You can use the `setUserId` function to identify a user with any string. It's best to use the default identifier you use in your app (e.g. unique id from database) but you can also anonymize these as long as they are unique for every user. This function can be called multiple times with the same value safely and stores the identifier in local storage. We recommend you set the User ID whenever the user logs in to your website, as well as after the installation snippet (if the user is already logged in). +To enable the User identification feature you need to set the `userId` in the init() call of Formbricks. Only when the `userId` is set the person will be visible in the Formbricks dashboard. The `userId` can be any string and it's best to use the default identifier you use in your app (e.g. unique id from database or the email address if it's unique) but you can also anonymize these as long as they are unique for every user. + ```javascript -formbricks.setUserId("USER_ID"); +formbricks.init({ + environmentId: "", + apiHost: "", + userId: "", +}); ``` + +## Enhanced Initialization with User Attributes + +In addition to setting the `userId`, Formbricks allows you to set user attributes right at the initialization. This ensures that your user data is seamlessly integrated from the start. Here's how you can include user attributes in the `init()` function: + + + + +```javascript +formbricks.init({ + environmentId: "", + apiHost: "", + userId: "", + attributes: { + // your custom attributes + Plan: "premium", + }, +}); +``` + + + + ## Setting User Email -You can use the setEmail function to set the user's email: +The `userId` is the main identifier used in Formbricks and user identification is only enabled when it is set. In addition to the userId you can also set attributes that describes the user better. The email address can be set using the setEmail function: + @@ -39,11 +68,12 @@ formbricks.setEmail("user@example.com"); ### Setting Custom User Attributes You can use the setAttribute function to set any custom attribute for the user (e.g. name, plan, etc.): + ```javascript -formbricks.setAttribute("attribute_key", "attribute_value"); +formbricks.setAttribute("Plan", "free"); ``` @@ -51,6 +81,7 @@ formbricks.setAttribute("attribute_key", "attribute_value"); ### Logging Out Users When a user logs out of your webpage, make sure to log them out of Formbricks as well. This will prevent new activity from being associated with an incorrect user. Use the logout function: + @@ -59,4 +90,4 @@ formbricks.logout(); ``` - \ No newline at end of file + diff --git a/apps/formbricks-com/app/docs/attributes/why/page.mdx b/apps/formbricks-com/app/docs/attributes/why/page.mdx index b5e681f657..aaafa71fd2 100644 --- a/apps/formbricks-com/app/docs/attributes/why/page.mdx +++ b/apps/formbricks-com/app/docs/attributes/why/page.mdx @@ -1,4 +1,4 @@ -export const meta = { +export const metadata = { title: "Understanding User Attributes in Formbricks Surveys", description: "Dive into the importance of attributes in surveys. Learn how key-value pairs can significantly improve survey targeting, enhance feedback quality, and guide data-driven decisions with Formbricks.", diff --git a/apps/formbricks-com/app/docs/best-practices/cancel-subscription/page.mdx b/apps/formbricks-com/app/docs/best-practices/cancel-subscription/page.mdx index e6d32dc9e3..4e44d6e09a 100644 --- a/apps/formbricks-com/app/docs/best-practices/cancel-subscription/page.mdx +++ b/apps/formbricks-com/app/docs/best-practices/cancel-subscription/page.mdx @@ -10,7 +10,7 @@ import RecontactOptions from "./recontact-options.webp"; import PublishSurvey from "./publish-survey.webp"; import SelectAction from "./select-action.webp"; -export const meta = { +export const metadata = { title: "Mastering Churn Surveys with Formbricks | Essential Tips & Steps", description: "Learn how to effectively utilize Formbricks' Churn Surveys to gain deeper insights into user departures. Dive into a step-by-step guide to craft, trigger, and optimize your churn surveys, ensuring you capture invaluable feedback at critical junctures", }; @@ -23,7 +23,7 @@ Churn is hard, but can teach you a lot. Whenever a user decides that your produc ## Purpose -The Churn Survey is among the most effective ways to identify weaknesses in you offering. People were willing to pay but now are not anymore: What changed? Let’s find out! +The Churn Survey is among the most effective ways to identify weaknesses in your offering. People were willing to pay but now are not anymore: What changed? Let’s find out! ## Preview diff --git a/apps/formbricks-com/app/docs/best-practices/docs-feedback/page.mdx b/apps/formbricks-com/app/docs/best-practices/docs-feedback/page.mdx index 8005857bea..dbf6cd7d21 100644 --- a/apps/formbricks-com/app/docs/best-practices/docs-feedback/page.mdx +++ b/apps/formbricks-com/app/docs/best-practices/docs-feedback/page.mdx @@ -10,7 +10,7 @@ import SwitchToDev from "./switch-to-dev.webp"; import WhenToAsk from "./when-to-ask.webp"; import CopyIds from "./copy-ids.webp"; -export const meta = { +export const metadata = { title: "Integrate Docs Feedback in Your Website: A Step-by-Step Guide on getting feedback on your Documentation with Formbricks", description: @@ -40,7 +40,7 @@ To get this running, you'll need a bit of time. Here are the steps we're going t 3. Connect to API 4. Test -### 1. Setting up Formbricks Cloud +## 1. Setting up Formbricks Cloud 1. To get started, create an account for the [Formbricks Cloud](https://app.formbricks.com/auth/signup). @@ -74,7 +74,7 @@ To get this running, you'll need a bit of time. Here are the steps we're going t 5. In the same way, you can change the Internal Question ID of the _Please elaborate_ question to **“additionalFeedback”** and the one of the _Page URL_ question to **“pageUrl”**. - ## Answers need to be identical If you want different answers than “Yes 👍” and “No 👎” you need update the + Answers need to be identical If you want different answers than “Yes 👍” and “No 👎” you need to update the choices accordingly. They have to be identical to the frontend we're building in the next step. @@ -108,10 +108,10 @@ To get this running, you'll need a bit of time. Here are the steps we're going t **You’re all setup in Formbricks Cloud for now 👍** -### 2. Build the frontend +## 2. Build the frontend - ## Your frontend might work differently Your frontend likely looks and works differently. This is an example + Your frontend might work differently Your frontend likely looks and works differently. This is an example specific to our tech stack. We want to illustrate what you should consider building yours 😊 @@ -311,7 +311,7 @@ return ( ## 3. Connecting to the Formbricks API -The last step is to hook up your sparkling new frontend to the Formbricks API. To do so, we followed the “[Create Response](/docs/client-api/create-response)” and “[Update Response](/docs/client-api/update-response)” pages in our docs. +The last step is to hook up your sparkling new frontend to the Formbricks API. To do so, we followed the “[Create Response](/docs/api/client/responses#create-a-response)” and “[Update Response](/docs/api/client/responses#update-a-response)” pages in our docs. Here is the code for the `handleFeedbackSubmit` function with comments: diff --git a/apps/formbricks-com/app/docs/best-practices/feature-chaser/page.mdx b/apps/formbricks-com/app/docs/best-practices/feature-chaser/page.mdx index 68d68cb324..030cae7765 100644 --- a/apps/formbricks-com/app/docs/best-practices/feature-chaser/page.mdx +++ b/apps/formbricks-com/app/docs/best-practices/feature-chaser/page.mdx @@ -9,7 +9,7 @@ import Publish from "./publish.webp"; import RecontactOptions from "./recontact-options.webp"; import SelectAction from "./select-action.webp"; -export const meta = { +export const metadata = { title: "Setting Up Feature Chaser Surveys with Formbricks: A Comprehensive Guide", description: "Learn how to harness the power of Formbricks to gather targeted user feedback on specific features. Dive deep into creating, triggering, and publishing the Feature Chaser survey to enhance your product with actionable insights for specific users.", }; @@ -22,7 +22,7 @@ Following up on specific features only makes sense with very targeted surveys. F ## Purpose -Product analytics never tell you why a feature is used - and why not. Following up on specfic features with highly relevant questions is a great way to gather feedback and improve your product. +Product analytics never tell you why a feature is used - and why not. Following up on specific features with highly relevant questions is a great way to gather feedback and improve your product. ## Preview diff --git a/apps/formbricks-com/app/docs/best-practices/feedback-box/page.mdx b/apps/formbricks-com/app/docs/best-practices/feedback-box/page.mdx index 635a3f3240..ac1f78b2f2 100644 --- a/apps/formbricks-com/app/docs/best-practices/feedback-box/page.mdx +++ b/apps/formbricks-com/app/docs/best-practices/feedback-box/page.mdx @@ -11,7 +11,7 @@ import PublishSurvey from "./publish-survey.webp"; import SelectAction from "./select-feedback-button-action.webp"; import RecontactOptions from "./set-recontact-options.webp"; -export const meta = { +export const metadata = { title: "Implementing the Feedback Box with Formbricks: A Step-by-Step Tutorial", description: "Unlock user insights effortlessly! Discover how to set up the Feedback Box in your app using Formbricks, allowing your users to provide real-time feedback. Follow our comprehensive guide to enhance user experience and respond rapidly to feedback", }; diff --git a/apps/formbricks-com/app/docs/best-practices/improve-trial-cr/page.mdx b/apps/formbricks-com/app/docs/best-practices/improve-trial-cr/page.mdx index ba0dec1876..3f770b4c95 100644 --- a/apps/formbricks-com/app/docs/best-practices/improve-trial-cr/page.mdx +++ b/apps/formbricks-com/app/docs/best-practices/improve-trial-cr/page.mdx @@ -9,7 +9,7 @@ import Publish from "./publish.webp"; import RecontactOptions from "./recontact-options.webp"; import SelectAction from "./select-action.webp"; -export const meta = { +export const metadata = { title: "Boost Your Trial Conversion Rates with Formbricks: Comprehensive Guide", description: "Unlock the secret to converting more trial users into paying customers using Formbricks. Understand insights behind trial cancellations and tailor your offering to fit user needs. Dive into our step-by-step tutorial and improve your conversion strategy today", }; diff --git a/apps/formbricks-com/app/docs/best-practices/interview-prompt/page.mdx b/apps/formbricks-com/app/docs/best-practices/interview-prompt/page.mdx index 0a49af1a98..077bf25e83 100644 --- a/apps/formbricks-com/app/docs/best-practices/interview-prompt/page.mdx +++ b/apps/formbricks-com/app/docs/best-practices/interview-prompt/page.mdx @@ -12,7 +12,7 @@ import Publish from "./publish-survey.webp"; import RecontactOptions from "./recontact-options.webp"; import SelectAction from "./select-action.webp"; -export const meta = { +export const metadata = { title: "Maximize User Interview Participation with In-app Interview Prompts", description: "Engage with your power users seamlessly using Formbricks' In-app Interview Prompt. Ditch traditional email invites and experience way more more respondents. Dive into our comprehensive guide on setting up auto-scheduled interviews today and enhance your user understanding", }; @@ -112,7 +112,7 @@ To create the trigger to show your Interview Prompt, go to the “Audience” ta appear in your Actions overview as long as the SDK is embedded. -Generally, we have two types of user actions: Page views and clicks. The Interview Prompt, you’ll likely want to display on a page visit since you already filter who sees the prompt by attributes. +Generally, we have two types of user actions: Page views and clicks. The Interview Prompt, you’ll likely want to display it on a page visit since you already filter who sees the prompt by attributes. 1. **pageURL:** Whenever a user visits a page the survey will be displayed, as long as the other conditions match. Other conditions are pre-segmentation, if this user has seen a survey in the past 2 weeks, etc. diff --git a/apps/formbricks-com/app/docs/best-practices/pmf-survey/page.mdx b/apps/formbricks-com/app/docs/best-practices/pmf-survey/page.mdx index 80f693ff4f..fbce45122b 100644 --- a/apps/formbricks-com/app/docs/best-practices/pmf-survey/page.mdx +++ b/apps/formbricks-com/app/docs/best-practices/pmf-survey/page.mdx @@ -9,7 +9,7 @@ import Publish from "./publish.webp"; import RecontactOptions from "./recontact-options.webp"; import SelectAction from "./select-action.webp"; -export const meta = { +export const metadata = { title: "How to Set Up a Product-Market Fit Survey Using Formbricks - Step-by-Step Guide", description: "Learn to leverage Formbricks to create and implement a Product-Market Fit survey in your web app. Follow our detailed step-by-step guide to measure and understand your PMF effectively. Ensure high data quality, efficient triggers, and actionable insights.", }; diff --git a/apps/formbricks-com/app/docs/contributing/creating-a-service/page.mdx b/apps/formbricks-com/app/docs/contributing/creating-a-service/page.mdx new file mode 100644 index 0000000000..e8afc5f158 --- /dev/null +++ b/apps/formbricks-com/app/docs/contributing/creating-a-service/page.mdx @@ -0,0 +1,280 @@ +import Image from "next/image"; +import UnstableCache from "./unstable-cache-documentation.webp"; + +export const metadata = { + title: "Formbricks Code Contribution Guide: How to create a service in Formbricks", + description: + "Services are the core backbone of the Formbricks codebase. This is the complete guide to help you create a service in Formbricks.", +}; + +#### Contributing + +# How to Create a Service + +In this guide, you will learn how to create a new service in Formbricks codebase. To begin let’s define what we mean when we use the word `Service` + + +A service is an abstraction of database calls related to a specific model in the database which comprises of cached functions that can perform generic database level functionalities. + + + +Let’s break down some of the jargon in that definition: + +**Abstraction of database calls** + +From our guide on [How we Code at Formbricks](https://formbricks.com/docs/contributing/how-we-code), we mention that database calls should not be made directly from components or other places other than a **service**. This means that if you need to make a request to the database to fetch some data, let’s say “get the **surveys** of the current user in the current **environment**”, you would need a function in the surveys service like `getSurveysByEnvironmentId`. It is also worth mentioning that we use [Prisma](https://prisma.io/) as a database abstraction layer to perform database calls. + +**Comprises of cached functions** + +A service consists of multiple functions that can be easily reused in server actions. The other important part of this is that the output of a function in a service MUST be cached so we don’t have make unnecessary database calls for data that hasn’t changed. We will talk more about caching in services a bit later. + +**Generic database level functionalities** + +By generic we mean that if in the `survey` service there is a function that only gets a survey and now you want a function to get both survey and all its responses, you should not create another function specifically for that. Instead use the `getSurvey` function and then a `getResponsesBySurveyId` function in the `response` service to get this data. The functions need to be generic so that they can be reused for cases like this where you need to combine multiple cached functions to get what you need. + +## Do you need a new service? + +Firstly you must note that you almost won’t need to create a new service unless a new model was created. If you think that you need a new service or a new function in an existing service, first double check if you can combine one or two existing functions in an existing service to achieve what you want. If you still think that it doesn’t meet your need, please discuss with Matti first with your specific use-case to get the green light to create a new service or function in a service. + +This is critical to us as a project because services are a key part of our project and we want to make them as organised, minimal, easy to change and use as possible. This is important to us as a team to move quickly and still keep a good and maintainable codebase. + +## Steps to creating a new service + +Below is a break down on how to create a new service, if you ned to implement a function in an existing service you can jump to Step 3: + +### Step 1: Create the service folder in `packages/lib` + +For the sake of this section, let’s say we just added a new model called `ApiKey`, (note this model already exists) + + + + +```sql +model ApiKey { + id String @id @unique @default(cuid()) + createdAt DateTime @default(now()) + lastUsedAt DateTime? + label String? + hashedKey String @unique() + environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade) + environmentId String +} +``` + + + + +**Step 1a**: The first thing you need to do is go to `packages/lib` and create a new folder called `apiKey`, note that this is the camel cased version of the Model name. + +**Step 1b**: We need to create the types for our service once we have the model. To do that you go to `packages/types` and create a file called `apiKey.ts`. + +In the type file, we must first create a Zod type that matches the Prisma model called`ZApiKey` (note here that it MUST begin with `Z` (indicating a Zod type) then the service name in pascal case). Next from this Zod type, we create a derived Typescript type called `TApiKey` (this MUST begin with a `T` and then the service name in pascal case). + +The reason we need both of them is because the Zod type is used for validating arguments passed into a service and we use the Typescript type to specify what data type a service function returns. + +### Step 2: Create `service.ts` and `cache.ts` in the service folder. + +The 2 required files are `service.ts` and `cache.ts`, note they are in singular form. + +`service.ts` - Where all the reusable cached functions are placed. + +`cache.ts` - Where the caching functionality for that service is abstracted to. + +### Step 3: Writing your functions in `service.ts` . + +A function in a service must have the following requirements: + +1. Follow the same naming pattern as we have in other services + - If using Prisma’s `findUnique` then the name should be `get` + `ServiceName` (in singular), e.g `getApiKey` + - If using Prisma’s `findMany` then the name should be `get` + `ServiceName` (in plural), e.g `getApiKeys` + - If your function's primary purpose is to retrieve or manipulate data based on a specific attribute or property of a resource, use "`by`" followed by the attribute name. For example: + - **`getMembersByTeamId`**: This function retrieves members filtered by the team's ID. + - **`getMembershipByUserIdTeamId`**: It retrieves a membership by the user's and team's IDs. + - If using Prisma’s `create` then `createApiKey` + - If using Prisma’s `update` then `updateApiKey` + - if using Prisma’s `delete` then `deleteApiKey` +2. All its arguments must be properly typed. +3. It should have a return type. +4. The arguments should be validated using `validateInputs` (reference the code to see how it is used) +5. Every function must return the standardised data types (`TApiKey`), including create or delete functions. +6. Handle errors in the function and return specific error types for DatabaseErrors. + + + A standardised data type is the derived Typescript type in this case `TApiKey` that matches the model of the + service. + + +Here is an example of a function that gets an api key by id: + + + + +```ts +export const getApiKey = async (apiKeyId: string): Promise => { + validateInputs([apiKeyId, ZString]); + + try { + const apiKeyData = await prisma.apiKey.findUnique({ + where: { + id: apiKeyId, + }, + }); + + if (!apiKeyData) { + throw new ResourceNotFoundError("API Key from ID", apiKeyId); + } + + return apiKeyData; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + + throw error; + } +}; +``` + + + + +### Step 4: Implementing caching for your function + +**Step 4a**: Firstly in the cache.ts file, you need to follow this structure: + + + + +```ts +import { revalidateTag } from "next/cache"; + +interface RevalidateProps { + id?: string; + environmentId?: string; +} + +export const apiKeyCache = { + tag: { + // Tags can be different depending on your use case + byId(id: string) { + return `apiKeys-${id}`; + }, + byEnvironmentId(environmentId: string) { + return `environments-${environmentId}-apiKeys`; + }, + }, + revalidate({ id, environmentId }: RevalidateProps): void { + if (id) { + revalidateTag(this.tag.byId(id)); + } + + if (environmentId) { + revalidateTag(this.tag.byEnvironmentId(environmentId)); + } + }, +}; +``` + + + + +_Breakdown of the above code._ + +1. **apiKeyCache**: The name of this object is `serviceName` + `Cache`, which is why this is called `apiKeyCache` . +2. **tag**: This object is where all the tags for the service cache will be stored. Read below for the definition of a tag +3. **byId**: This is the required tag, since every service must query by Id at some point, `byId` is a must have in each tag. It is used to revalidate the cache of a single item, e.g. `getApiKey(id)`. If there is a good reason not to query by id, you can avoid creating this tag. The returned string of this function needs to begin with the service name in plural then a dash and the id (which must be passed in). +4. **byEnvironmentId**: It is used to revalidate the cache of a list of items of the same parent, e.g. `getApiKeys(environmentId)`. For parent dependencies used to query this service, you should add the plural of the name in this case `environments` plus the id of the parent dependency plus the name of the service you are working with in plural, in this case `apiKeys` which results to `environments-${environmentId}-apiKeys`. +5. **revalidate**: This function receives an object with optional keys. Depending on the key that is passed in, we optionally call the `revalidateTag` from `next/cache` on the appropriate tag. Note each key passed into this function has to match a `tag`. + + + A tag is a label or metadata identifier attached to a piece of data, content, or an object to categorize, + classify, or organize it for easier retrieval, grouping, or management. In the context of revalidation, tags + are used to associate groups of cached data with specific events or triggers. When an event occurs, such as + a form submission or content update, the tags are used to identify and revalidate all the cached data items + associated with that tag. This ensures that the latest and most up-to-date data is retrieved and displayed + in response to the event, contributing to the effective management and real-time updating of cached content. + + + + We have a [script](https://gist.github.com/rotimi-best/7bd7e4ebda09a68ff0a1dc8ae6fa0009) that can help you + auto-generate the `cache.ts` file with the basic structure. + + +**Step 4b:** Now that you have the `cache.ts`, it is time to actually use the tags and revalidate method in your `service.ts`. + +We will rewrite the function `getApiKey` we created in the `service.ts` file to support caching: + + + + +```ts +import { unstable_cache } from "next/cache"; +import { SERVICES_REVALIDATION_INTERVAL } from "../constants"; +import { apiKeyCache } from "./cache"; + +export const getApiKey = async (apiKeyId: string): Promise => + unstable_cache( + async () => { + validateInputs([apiKeyId, ZString]); + + try { + const apiKeyData = await prisma.apiKey.findUnique({ + where: { + id: apiKeyId, + }, + }); + + if (!apiKeyData) { + throw new ResourceNotFoundError("API Key from ID", apiKeyId); + } + + return apiKeyData; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + + throw error; + } + }, + [`getApiKey-${apiKeyId}`], + { + tags: [apiKeyCache.tag.byId(apiKeyId)], + revalidate: SERVICES_REVALIDATION_INTERVAL, + } + )(); +``` + + + + +_Breakdown of the above code._ + +In the above code we only introduce something new called `unstable_cache`, read more about it [here](https://nextjs.org/docs/app/api-reference/functions/unstable_cache#parameters). In a nutshell these are its parameters: + +Unstable Cache Parameters + +From the screenshot above we see that `unstable_cache` receives 3 arguments: + +1. `fetchData`: In our case this is the exact function of your service without caching (step 3) +2. `keyParts`: As a rule of thumb, the key must consist of the name of the function and the arguments passed into the function, all separated by a dash. In our case it is called `getApiKey-${apiKeyId}` because the function name is `getApiKey` and we receive only one argument called `apiKeyId` +3. `options`: which consists of **tags** and **revalidate** + 1. `tags`: This is where the tags you created in step 4a comes in, tags are created solely based on the arguments passed to the function. (please reference existing services in `packages/lib` to see more variations of this when dealing with more than one argument) + 2. `revalidate`: We have a global constant for this which you can use called `SERVICES_REVALIDATION_INTERVAL` + + +In create, update and delete requests, you don’t need caching however these are the places where the revalidate method is called. For example when the apiKey is deleted we want to call the revalidate method and pass in the id and environmentId, so we invalidate every cached function with `id` and `environmentId` tags. +`apiKeyCache.revalidate({ id: [apiKey.id](http://apikey.id/), environmentId: apiKey.environmentId });` + + + +### Step 5: Check if you need to add these 2 optional files (`auth.ts` and `util.ts`) + +`auth.ts` - Is for verifying if the user is authorised to access the service. Typically it has only one function with this naming `canUserAccessApiKey`. Please note that ApiKey at the end of the name is specific to the service name. + +`util.ts` - This file holds any helper function that is used in that specific service. For example one common use case for this files is for converting Date fields from string to Date. The reason for this is that when we cache a function using `unstable_cache`, [it does not support deserialisation of dates](https://github.com/vercel/next.js/issues/51613). We therefore need to manually deserialise date fields by writing a function that receives the data of a service and we check for its date fields that are in strings and we convert them into Date. diff --git a/apps/formbricks-com/app/docs/contributing/creating-a-service/unstable-cache-documentation.webp b/apps/formbricks-com/app/docs/contributing/creating-a-service/unstable-cache-documentation.webp new file mode 100644 index 0000000000..b2d7119704 Binary files /dev/null and b/apps/formbricks-com/app/docs/contributing/creating-a-service/unstable-cache-documentation.webp differ diff --git a/apps/formbricks-com/app/docs/contributing/demo/page.mdx b/apps/formbricks-com/app/docs/contributing/demo/page.mdx index cdce0be8b6..b91ce0f802 100644 --- a/apps/formbricks-com/app/docs/contributing/demo/page.mdx +++ b/apps/formbricks-com/app/docs/contributing/demo/page.mdx @@ -2,9 +2,10 @@ import Image from "next/image"; import DemoApp from "./demoapp.webp"; -export const meta = { +export const metadata = { title: "Formbricks Demo App Guide: Play around with Formbricks", - description: "To test in-app surveys, trigger actions and set attributes, you can use the Demo App. This guide provides hands-on examples of sending both code and no-code actions", + description: + "To test in-app surveys, trigger actions and set attributes, you can use the Demo App. This guide provides hands-on examples of sending both code and no-code actions", }; #### Contributing @@ -13,13 +14,14 @@ export const meta = { To play around with the in-app [User Actions](/docs/actions/why), you can use the Demo App. It's a simple React app that you can run locally and use to trigger actions and set [Attributes](/docs/attributes/why). -Demo App Preview +Demo App Preview ## Functionality ### Code Action This button sends a Code Action to the Formbricks API called 'Code Action'. You will find it in the Actions Tab. + @@ -32,6 +34,7 @@ formbricks.track("Code Action"); ### No Code Action This button sends a No Code Action as long as you created it beforehand in the Formbricks App. For it to work, you need to add the No Code Action within Formbricks. + @@ -44,6 +47,7 @@ This button sends a No Code Action as long a ### Set Plan to "Free" This button sets the attribute 'Plan' to 'Free'. If the attribute does not exist, it creates it. + @@ -56,6 +60,7 @@ formbricks.setAttribute("Plan", "Free"); ### Set Plan to "Paid" This button sets the attribute 'Plan' to 'Paid'. If the attribute does not exist, it creates it. + @@ -68,6 +73,7 @@ formbricks.setAttribute("Plan", "Paid"); ### Set Email This button sets the user email 'test@web.com' + @@ -79,13 +85,14 @@ formbricks.setEmail("test@web.com"); ### Set UserId -This button sets an external user ID to 'THIS-IS-A-VERY-LONG-USER-ID-FOR-TESTING' +This button sets an external user ID in the Formbricks init call to 'THIS-IS-A-VERY-LONG-USER-ID-FOR-TESTING' + ```tsx -formbricks.setUserId("THIS-IS-A-VERY-LONG-USER-ID-FOR-TESTING"); +userId: "THIS-IS-A-VERY-LONG-USER-ID-FOR-TESTING"; ``` - \ No newline at end of file + diff --git a/apps/formbricks-com/app/docs/contributing/gitpod/page.mdx b/apps/formbricks-com/app/docs/contributing/gitpod/page.mdx deleted file mode 100644 index f431822533..0000000000 --- a/apps/formbricks-com/app/docs/contributing/gitpod/page.mdx +++ /dev/null @@ -1,141 +0,0 @@ -import Image from "next/image"; -import GitpodPorts from "./gitpod-ports.webp"; -import GitpodAuth from "./gitpod-auth.webp"; -import GitpodNewWorkspace from "./gitpod-new-workspace.webp"; -import GitpodPreparing from "./gitpod-preparing.webp"; -import GitpodRunning from "./gitpod-running.webp"; - -export const meta = { - title: "Gitpod Setup", - description: - "With one click, you can setup the Formbricks developer environment in your browser using Gitpod", -}; - -#### Contributing - -# Gitpod - -### One Click Setup - -- This will open a fully configured workspace in your browser with all the necessary dependencies already installed. - -- Click the button below to open this project in Gitpod. - -[![Open in Gitpod](https://gitpod.io/button/open-in-gitpod.svg)](https://gitpod.io/#https://github.com/formbricks/formbricks) - -## Gitpod Setup Overview - - **Building custom image for the workspace:** - - This includes : Installing `yq` and `turbo` globally before the workspace starts. This is accomplished within the `.gitpod.Dockerfile` along with starting upon a base custom image building on [workspace-full](https://hub.docker.com/r/gitpod/workspace-full/dockerfile). - - **Initialization of Formbricks:** - - During the prebuilds phase, we initialize Formbricks by performing the following tasks: - 1. Setting up environment variables. - 2. Installing monorepo dependencies. - 3. Installing Docker images by extracting them from the `packages/database/docker-compose.yml` file. - 4. Building the @formbricks/js component. - - When the workspace starts: - 1. Waiting for web and demo apps to start and openening the `apps/demo/.env` file automatically such that users can start playing around with the demo app by configuring `NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID` straight away! - - **Web Component Initialization:** - - we initialize the @formbricks/web component during prebuilds. This involves: - 1. Installing build dependencies for the `@formbricks/web#go` task from turbo.json in prebuilds to save time. - 2. Starting PostgreSQL and Mailhog containers for running migrations in prebuilds. - 3. To prevent the "Init" task from running indefinitely due to prebuild rules, a cleanup `docker compose down` step i.e. `db:down` is added to `turbo.json`. This step is designed to halt the execution of containers that are currently running. - - When the workspace starts: - 1. Initializing environment variables. - 2. Replacing `NEXT_PUBLIC_WEBAPP_URL` and `NEXTAUTH_URL` to take in Gitpod URL's ports when running on VSCode browser. - 3. Starting the `@formbricks/web` dev environment. - - **Demo Component Initialization:** - - Similar to the web component, the demo component is also initialized during prebuilds. This includes: - 1. Installing build dependencies for the `formbricks/demo#go` task from turbo.json in prebuilds to save time. - 2. Caching hits and replaying builds from the `@formbricks/js` component. - - When the workspace starts: - 1. Initializing environment variables. - 2. Replaces `NEXT_PUBLIC_FORMBRICKS_API_HOST` to take in Gitpod URL's ports when running on VSCode browser. - 3. Starting the `@formbricks/demo` dev environment. - - **GitHub Prebuilds Configuration:** - - This configures GitHub Prebuilds for the master branch, pull requests, and adding comments. This helps automate the prebuild process for the specified branches and actions. - - **VSCode Extensions:** - - This includes a list of VSCode extensions that are added to the configuration when using Gitpod. These extensions can enhance the development experience within Gitpod. - - -## Gitpod Guide - -### 1. Browser Redirection - -After clicking the one-click setup button, Gitpod will open a new tab or window. Please ensure that your browser allows redirection to successfully access the services: - -### 2. Authorizing in Gitpod -Gitpod Auth Page -- This is the Gitpod Authentication Page. It appears when you click the "Open in GitPod" button and Gitpod needs to authenticate your access to the workspace. Click on 'Continue With Github' to authorize your GitPod session. - -### 3. Creating a New Workspace -Gitpod New Worskpace Page -- After authentication, Gitpod asks to create a new workspace for you. This page displays the configurations of your workspace. -- You can use either choose either VS Code Browser or VS Code Desktop editor with the 'Standard Class' for your workspace class. -- If you opt for the VS Code Desktop, follow the following steps - 1. Gitpod will prompt you to grant access to the VSCode app. Once approved, install the GitPod extension from the VSCode Marketplace and follow the prompts to authorize the integration. - 2. Change the `WEBAPP_URL` and the `NEXTAUTH_URL` to `https://localhost:3000` - - -### 4. Gitpod preparing the created Workspace -Gitpod Preparing Worskpace Page -- Gitpod is preparing your workspace with all the necessary dependencies and configurations. You will see this page while Gitpod sets up your development environment. - -### 5. Gitpod running the Workspace -Gitpod Running Workspace Page -- Once the workspace is fully prepared, voila, it enters the running state. You can start working on your project in this environment. - -### Ports and Services - -Here are the ports and corresponding URLs for the services within your Gitpod environment: - -- **Port 3000**: - - **Service**: Demo App - - **Description**: This port hosts the demo application of your project. You can access and interact with your application's demo by navigating to this port. - -- **Port 3001**: - - **Service**: Formbricks website - - **Description**: This port hosts the [Formbricks](https://formbricks.com) website, which contains documents, pricing, blogs, best practices, and concierge service. - -- **Port 3002**: - - **Service**: Formbricks In-product Survey Demo App - - **Description**: This app helps you test your in-app surveys. You can create and test user actions, create and update user attributes, etc. - -- **Port 5432**: - - **Service**: PostgreSQL Database Server - - **Description**: The PostgreSQL DB is hosted on this port. - -- **Port 1025**: - - **Service**: SMPT server - - **Description**: SMTP Server for sending and receiving email messages. This server is responsible for handling email communication. - -- **Port 8025**: - - **Service**: Mailhog - -### Accessing port URLs - 1. **Direct URL Composition**: - - You can access the dedicated port URL by pre-pending the port number to the workspace URL. - - For example, if you want to access port 3000, you can use the URL format: `3000-yourworkspace.ws-eu45.gitpod.io`. - - 2. **Using [gp CLI](https://www.gitpod.io/docs/references/gitpod-cli)**: - - Gitpod provides a convenient command, `gp url`, to quickly retrieve the URL for a specific port. - - Simply use the command followed by the desired port number. For example, to get the URL for port 3000, run: `gp url 3000`. - - 3. **Listing All Open Port URLs**: - - If you prefer to see a list of all open port URLs at once, you can use the `gp ports list` command. - - Running this command will display a list of ports along with their corresponding URLs. - - 4. **Viewing All Ports in Panel**: - - Gitpod also offers a user-friendly 'Ports' tab in the Gitpod panel. - - Click on the 'Ports' tab to view a list of all open ports and their respective URLs. - - Gitpod Ports tab - - These URLs and port numbers represent various services and endpoints within your Gitpod environment. You can access and interact with these services by the Port URL for the respective service. - -Still can’t figure it out? Join our [Discord](https://discord.com/invite/3YFcABF2Ts)! \ No newline at end of file diff --git a/apps/formbricks-com/app/docs/contributing/how-we-code/page.mdx b/apps/formbricks-com/app/docs/contributing/how-we-code/page.mdx index d32db23935..fe6fdd369b 100644 --- a/apps/formbricks-com/app/docs/contributing/how-we-code/page.mdx +++ b/apps/formbricks-com/app/docs/contributing/how-we-code/page.mdx @@ -1,7 +1,7 @@ import Image from "next/image"; import CorsHandling from "./cors-handling-in-api.webp"; -export const meta = { +export const metadata = { title: "Formbricks Code Contribution Guide: Best Practices and Standards", description: "Effortlessly Navigate Your Contribution Journey with Formbricks' Coding Guidelines and PR Review Process", @@ -81,7 +81,7 @@ You should store constants in `packages/lib/constants` ## Types should be in the packages folder -You should store type in `packages/types/v1` +You should store type in `packages/types` ## Read environment variables from `.env.mjs` diff --git a/apps/formbricks-com/app/docs/contributing/introduction/page.mdx b/apps/formbricks-com/app/docs/contributing/introduction/page.mdx index e852110835..1825ed1227 100644 --- a/apps/formbricks-com/app/docs/contributing/introduction/page.mdx +++ b/apps/formbricks-com/app/docs/contributing/introduction/page.mdx @@ -1,4 +1,4 @@ -export const meta = { +export const metadata = { title: "Formbricks Open Source Contribution Guide: How to Enhance yourself and Contribute to Formbricks", description: "Join the Formbricks community and learn how to effectively contribute. From raising issues and feature requests to creating PRs, discover the best practices and communicate with our responsive team on Discord", diff --git a/apps/formbricks-com/app/docs/contributing/setup/github-codespaces/env.webp b/apps/formbricks-com/app/docs/contributing/setup/github-codespaces/env.webp new file mode 100644 index 0000000000..f22e6b6722 Binary files /dev/null and b/apps/formbricks-com/app/docs/contributing/setup/github-codespaces/env.webp differ diff --git a/apps/formbricks-com/app/docs/contributing/setup/github-codespaces/loading.webp b/apps/formbricks-com/app/docs/contributing/setup/github-codespaces/loading.webp new file mode 100644 index 0000000000..89b3ceef2b Binary files /dev/null and b/apps/formbricks-com/app/docs/contributing/setup/github-codespaces/loading.webp differ diff --git a/apps/formbricks-com/app/docs/contributing/setup/github-codespaces/new.webp b/apps/formbricks-com/app/docs/contributing/setup/github-codespaces/new.webp new file mode 100644 index 0000000000..e415ffb87b Binary files /dev/null and b/apps/formbricks-com/app/docs/contributing/setup/github-codespaces/new.webp differ diff --git a/apps/formbricks-com/app/docs/contributing/setup/github-codespaces/ports.webp b/apps/formbricks-com/app/docs/contributing/setup/github-codespaces/ports.webp new file mode 100644 index 0000000000..766a3b396d Binary files /dev/null and b/apps/formbricks-com/app/docs/contributing/setup/github-codespaces/ports.webp differ diff --git a/apps/formbricks-com/app/docs/contributing/setup/github-codespaces/run.webp b/apps/formbricks-com/app/docs/contributing/setup/github-codespaces/run.webp new file mode 100644 index 0000000000..e50adb0771 Binary files /dev/null and b/apps/formbricks-com/app/docs/contributing/setup/github-codespaces/run.webp differ diff --git a/apps/formbricks-com/app/docs/contributing/setup/github-codespaces/terminal.webp b/apps/formbricks-com/app/docs/contributing/setup/github-codespaces/terminal.webp new file mode 100644 index 0000000000..c57df1bf22 Binary files /dev/null and b/apps/formbricks-com/app/docs/contributing/setup/github-codespaces/terminal.webp differ diff --git a/apps/formbricks-com/app/docs/contributing/gitpod/gitpod-auth.webp b/apps/formbricks-com/app/docs/contributing/setup/gitpod/auth.webp similarity index 100% rename from apps/formbricks-com/app/docs/contributing/gitpod/gitpod-auth.webp rename to apps/formbricks-com/app/docs/contributing/setup/gitpod/auth.webp diff --git a/apps/formbricks-com/app/docs/contributing/gitpod/gitpod-new-workspace.webp b/apps/formbricks-com/app/docs/contributing/setup/gitpod/new-workspace.webp similarity index 100% rename from apps/formbricks-com/app/docs/contributing/gitpod/gitpod-new-workspace.webp rename to apps/formbricks-com/app/docs/contributing/setup/gitpod/new-workspace.webp diff --git a/apps/formbricks-com/app/docs/contributing/gitpod/gitpod-ports.webp b/apps/formbricks-com/app/docs/contributing/setup/gitpod/ports.webp similarity index 100% rename from apps/formbricks-com/app/docs/contributing/gitpod/gitpod-ports.webp rename to apps/formbricks-com/app/docs/contributing/setup/gitpod/ports.webp diff --git a/apps/formbricks-com/app/docs/contributing/gitpod/gitpod-preparing.webp b/apps/formbricks-com/app/docs/contributing/setup/gitpod/preparing.webp similarity index 100% rename from apps/formbricks-com/app/docs/contributing/gitpod/gitpod-preparing.webp rename to apps/formbricks-com/app/docs/contributing/setup/gitpod/preparing.webp diff --git a/apps/formbricks-com/app/docs/contributing/gitpod/gitpod-running.webp b/apps/formbricks-com/app/docs/contributing/setup/gitpod/running.webp similarity index 100% rename from apps/formbricks-com/app/docs/contributing/gitpod/gitpod-running.webp rename to apps/formbricks-com/app/docs/contributing/setup/gitpod/running.webp diff --git a/apps/formbricks-com/app/docs/contributing/setup/page.mdx b/apps/formbricks-com/app/docs/contributing/setup/page.mdx index 6ddf3fbc29..87a6be3e50 100644 --- a/apps/formbricks-com/app/docs/contributing/setup/page.mdx +++ b/apps/formbricks-com/app/docs/contributing/setup/page.mdx @@ -1,91 +1,394 @@ -export const meta = { +import Image from "next/image"; + +import GitpodAuth from "./gitpod/auth.webp"; +import GitpodNewWorkspace from "./gitpod/new-workspace.webp"; +import GitpodPorts from "./gitpod/ports.webp"; +import GitpodPreparing from "./gitpod/preparing.webp"; +import GitpodRunning from "./gitpod/running.webp"; + +import GithubCodespaceEnvFile from "./github-codespaces/env.webp"; +import GithubCodespaceLoading from "./github-codespaces/loading.webp"; +import GithubCodespaceNew from "./github-codespaces/new.webp"; +import GithubCodespacePorts from "./github-codespaces/ports.webp"; +import GithubCodespaceRun from "./github-codespaces/run.webp"; +import GithubCodespaceTerminal from "./github-codespaces/terminal.webp"; + +export const metadata = { title: "Formbricks Development Setup: Complete Guide to Local Environment Configuration for Dev", - description: "Step-by-step guide to setting up your local development environment for Formbricks. Includes installing essential tools like Node.JS, pnpm, and Docker, and accessing the entire Formbricks stack including the Demo app and the main website", + description: + "Step-by-step guide to setting up a development environment for Formbricks. We officially support Gitpod and Github Codespaces for quick setup. Our advanced users can also setup Formbricks locally on their machine.", }; #### Contributing # Setup Dev Environment +We currently officially support the below methods to set up your development environment for Formbricks. + + + Both the below cloud IDEs have a **generous free tier** to explore and develop! But make sure to not overuse + the machines as Formbricks will not be responsible for any charges incurred. + + +### [GitPod](#gitpod) + +This will open a fully configured workspace in your browser with all the necessary dependencies already installed. Click the button below to open this project in Gitpod. For a detailed guide, visit the [Gitpod Setup Guide](#gitpod-guide) section below. + +[![Open in Gitpod](https://gitpod.io/button/open-in-gitpod.svg)](https://gitpod.io/#https://Github.com/formbricks/formbricks) + +### [Github Codespaces](#Github-codespaces) + +This will open a Github VSCode Interface on the cloud for you. This setup will have the Formbricks codebase and all the dependencies installed. Click the button below to configure your instance and open the project in Github Codespaces. For a detailed guide, visit the [Github Codespaces Setup Guide](#github-codespaces-guide) section below. + +[![Open in Github Codespaces](https://img.shields.io/badge/Open%20in-Github%20Codespaces-blue?logo=Github)](https://Github.com/codespaces/new?machine=standardLinux32gb&repo=500289888&ref=main&devcontainer_path=.devcontainer%2Fdevcontainer.json&location=EastUs2) + +### [Local Machine](#local-machine-setup) + +This will install the Formbricks codebase and all the dependencies on your local machine. Note that this method is recommended **only for advanced users**. If you're an advanced user, access the steps for [Local Machine Setup here](#local-machine-setup). + + + For a smooth experience, we suggest the above cloud IDE methods. Assistance with setup issues on your local + machine may be limited due to varying factors like OS and permissions. + + +## Gitpod Guide + + **Building custom image for the workspace:** + - This includes : Installing `yq` and `turbo` globally before the workspace starts. This is accomplished within the `.gitpod.Dockerfile` along with starting upon a base custom image building on [workspace-full](https://hub.docker.com/r/gitpod/workspace-full/dockerfile). + + **Initialization of Formbricks:** + - During the prebuilds phase, we initialize Formbricks by performing the following tasks: + 1. Setting up environment variables. + 2. Installing monorepo dependencies. + 3. Installing Docker images by extracting them from the `packages/database/docker-compose.yml` file. + 4. Building the @formbricks/js component. + - When the workspace starts: + 1. Wait for the web and demo apps to launch on Gitpod. This automatically opens the `apps/demo/.env` file. Utilize dynamic localhost URLs (e.g., `localhost:3000` for signup and `localhost:8025` for email confirmation) to configure `NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID`. After creating your account and finding the `ID` in the URL at `localhost:3000`, replace `YOUR_ENVIRONMENT_ID` in the `.env` file located in `app/demo`. + + **Web Component Initialization:** + - we initialize the @formbricks/web component during prebuilds. This involves: + 1. Installing build dependencies for the `@formbricks/web#go` task from turbo.json in prebuilds to save time. + 2. Starting PostgreSQL and Mailhog containers for running migrations in prebuilds. + 3. To prevent the "Init" task from running indefinitely due to prebuild rules, a cleanup `docker compose down` step i.e. `db:down` is added to `turbo.json`. This step is designed to halt the execution of containers that are currently running. + - When the workspace starts: + 1. Initializing environment variables. + 2. Replacing `NEXT_PUBLIC_WEBAPP_URL` and `NEXTAUTH_URL` to take in Gitpod URL's ports when running on VSCode browser. + 3. Starting the `@formbricks/web` dev environment. + + **Demo Component Initialization:** + - Similar to the web component, the demo component is also initialized during prebuilds. This includes: + 1. Installing build dependencies for the `formbricks/demo#go` task from turbo.json in prebuilds to save time. + 2. Caching hits and replaying builds from the `@formbricks/js` component. + - When the workspace starts: + 1. Initializing environment variables. + 2. Replaces `NEXT_PUBLIC_FORMBRICKS_API_HOST` to take in Gitpod URL's ports when running on VSCode browser. + 3. Starting the `@formbricks/demo` dev environment. + + **Github Prebuilds Configuration:** + - This configures Github Prebuilds for the master branch, pull requests, and adding comments. This helps automate the prebuild process for the specified branches and actions. + + **VSCode Extensions:** + - This includes a list of VSCode extensions that are added to the configuration when using Gitpod. These extensions can enhance the development experience within Gitpod. + +### 1. Browser Redirection + +After clicking the one-click setup button, Gitpod will open a new tab or window. Please ensure that your browser allows redirection to successfully access the services: + +### 2. Authorizing in Gitpod + +Gitpod Auth Page- +This is the Gitpod Authentication Page. It appears when you click the "Open in GitPod" button and Gitpod needs +to authenticate your access to the workspace. Click on 'Continue With Github' to authorize your GitPod session. + +### 3. Creating a New Workspace + +Gitpod New workspace Page +- After authentication, Gitpod asks to create a new workspace for you. This page displays the configurations of +your workspace. - You can use either choose either VS Code Browser or VS Code Desktop editor with the 'Standard +Class' for your workspace class. - If you opt for the VS Code Desktop, follow the following steps 1. Gitpod will +prompt you to grant access to the VSCode app. Once approved, install the GitPod extension from the VSCode Marketplace +and follow the prompts to authorize the integration. 2. Change the `WEBAPP_URL` and the `NEXTAUTH_URL` to `https://localhost:3000` + +### 4. Gitpod preparing the created Workspace + +Gitpod Preparing workspace Page +- Gitpod is preparing your workspace with all the necessary dependencies and configurations. You will see this +page while Gitpod sets up your development environment. + +### 5. Gitpod running the Workspace + +Gitpod Running Workspace Page +- Once the workspace is fully prepared, voila, it enters the running state. You can start working on your project +in this environment. + +### Ports and Services + +Here are the ports and corresponding URLs for the services within your Gitpod environment: + +- **Port 3000**: + + - **Service**: Demo App + - **Description**: This port hosts the demo application of your project. You can access and interact with your application's demo by navigating to this port. + +- **Port 3001**: + + - **Service**: Formbricks website + - **Description**: This port hosts the [Formbricks](https://formbricks.com) website, which contains documents, pricing, blogs, best practices, and concierge service. + +- **Port 3002**: + + - **Service**: Formbricks In-product Survey Demo App + - **Description**: This app helps you test your in-app surveys. You can create and test user actions, create and update user attributes, etc. + +- **Port 5432**: + + - **Service**: PostgreSQL Database Server + - **Description**: The PostgreSQL DB is hosted on this port. + +- **Port 1025**: + + - **Service**: SMPT server + - **Description**: SMTP Server for sending and receiving email messages. This server is responsible for handling email communication. + +- **Port 8025**: + - **Service**: Mailhog + +### Accessing port URLs + +1. **Direct URL Composition**: + +- You can access the dedicated port URL by pre-pending the port number to the workspace URL. +- For example, if you want to access port 3000, you can use the URL format: `3000-yourworkspace.ws-eu45.gitpod.io`. + +2. **Using [gp CLI](https://www.gitpod.io/docs/references/gitpod-cli)**: + +- Gitpod provides a convenient command, `gp url`, to quickly retrieve the URL for a specific port. +- Simply use the command followed by the desired port number. For example, to get the URL for port 3000, run: `gp url 3000`. + +3. **Listing All Open Port URLs**: + +- If you prefer to see a list of all open port URLs at once, you can use the `gp ports list` command. +- Running this command will display a list of ports along with their corresponding URLs. + +4. **Viewing All Ports in Panel**: + +- Gitpod also offers a user-friendly 'Ports' tab in the Gitpod panel. +- Click on the 'Ports' tab to view a list of all open ports and their respective URLs. + +{" "} + +Gitpod Ports tab + +These URLs and port numbers represent various services and endpoints within your Gitpod environment. You can access and interact with these services by the Port URL for the respective service. + +--- + +## Github Codespaces Guide + +1. After clicking the one-click setup button, you will be redirected to the Github Codespaces page. Review the configuration and click on the 'Create Codespace' button to create a new Codespace. + +New Github Codespace + +2. This will start loading the Codespace. Keep in mind this might take a few minutes to complete depending on your internet connection and the instance availability. + +Loading Github Codespace + +3. Once the Codespace is loaded, you will be redirected to the VSCode editor. You can start working on your project in this environment. + +4. Make the changes you want to, and now, to run the app, we first need to configure the .env file. Copy the .env.example and edit the variables as mentioned in the file itself. + +Github Codespace Env File + +5. Once you have configured the .env, it's now time to run the app and see the changes. Lets open the terminal first + +Github Codespace Open Terminal + +6. Now, run the following command to run the app + + + + +```bash +pnpm dev +``` + + + + +Run on Github Codespace + +7. Monitor the logs in the terminal and once you see the following, you are good to go! + + + + +```bash +@formbricks/web:dev: ▲ Next.js 13.5.6 +@formbricks/web:dev: - Local: http://localhost:3000 +@formbricks/web:dev: - Environments: .env +@formbricks/web:dev: - Experiments (use at your own risk): +@formbricks/web:dev: · serverActions +@formbricks/web:dev: +@formbricks/web:dev: ✓ Ready in 9.4s +``` + + + + +8. Right next to the Terminal, you will see a **Ports** tab, click on it to see the ports and their respective URLs. Now access the Forwarded Address for port 3000 and you should be able to visit your Formbricks App! + +Github Codespace Ports + +Now make the changes you want to and see them live in action! + +--- + +## Local Machine Setup + + +The below only works for **Mac**, **Linux** & **WSL2** on Windows (not on pure Windows)! + +This method is recommended **only for advanced users** & we won't be able to provide official support for this. + + + To get the project running locally on your machine you need to have the following development tools installed: -- Node.JS (we recommend v18) +- Node.JS (we recommend v20) - [pnpm](https://pnpm.io/) - [Docker](https://www.docker.com/) (to run PostgreSQL / MailHog) -1. Clone the project: +1. Clone the project & move into the directory: + - ```bash - git clone https://github.com/formbricks/formbricks - ``` +```bash +git clone https://github.com/formbricks/formbricks && cd formbricks +``` - and move into the directory - - - ```bash - cd formbricks - ``` +2. Install Node.JS packages via pnpm. Don't have pnpm? Get it [here](https://pnpm.io/installation) - - -1. Install Node.JS packages via pnpm. Don't have pnpm? Get it [here](https://pnpm.io/installation) - ```bash - pnpm install - ``` +```bash +pnpm install +``` -1. Create a `.env` file based on `.env.example`. It's already preset to work with the docker-compose setup but you can also change values if needed. + +3. Create a `.env` file based on `.env.example`. It's already preset to work with the local development setup but you can also change values if needed. + - ```bash - cp .env.example .env - ``` +```bash +cp .env.example .env +``` -1. Generate a secret value mandatory to be set for the key ENCRYPTION_KEY in the .env file. You can use the following command to generate the random string of required length: + +4. Generate & set some secret values mandatory for the ENCRYPTION_KEY & NEXTAUTH_SECRET in the .env file. You can use the following command to generate the random string of required length: + - ```bash - openssl rand -base64 24 - ``` +```bash +sed -i '/^ENCRYPTION_KEY=/c\ENCRYPTION_KEY='$(openssl rand -hex 32) .env +sed -i '/^NEXTAUTH_SECRET=/c\NEXTAUTH_SECRET='$(openssl rand -hex 32) .env +``` -1. Make sure you have [`Docker`](https://docs.docker.com/compose/) & [`docker-compose`](https://docs.docker.com/compose/) installed and running on your machine. Then run the following command to start the formbricks dev setup: +5. Make sure you have [`Docker`](https://docs.docker.com/compose/) & [`docker-compose`](https://docs.docker.com/compose/) installed and running on your machine. Then run the following command to start the Formbricks dev setup: + - ```bash - pnpm go - ``` +```bash +pnpm go +``` This starts the Formbricks main app (plus all its dependencies) as well as the following services using Docker: - - a `postgres` container for hosting your database, - - a `mailhog` container that acts as a mock SMTP server and shows received mails in a web UI (forwarded to your host's `localhost:8025`) +- a `postgres` container for hosting your database, +- a `mailhog` container that acts as a mock SMTP server and shows received mails in a web UI (forwarded to your host's `localhost:8025`) +- Demo App at [http://localhost:3002](http://localhost:3002) +- Landing Page at [http://localhost:3001](http://localhost:3001) - **You can now access the Formbricks app on [http://localhost:3000](http://localhost:3000)**. You will be automatically redirected to the login. To use your local installation of formbricks, create a new account. +**You can now access the Formbricks app on [http://localhost:3000](http://localhost:3000)**. You will be automatically redirected to the login. To use your local installation of formbricks, create a new account. - For viewing the confirmation email and other emails the system sends you, you can access mailhog at [http://localhost:8025](http://localhost:8025) +{" "} + + + A fresh setup does not have a default account. Please create a new account and proceed accordingly. + + +For viewing the emails sent by the system, you can access mailhog at [http://localhost:8025](http://localhost:8025) ### Build To build all apps and packages and check for build errors, run the following command: + @@ -95,30 +398,7 @@ pnpm build -### Access Demo app -To run the [Demo app](/docs/contributing/demo), run the following command in a separate terminal window: - - +--- -```bash -pnpm dev --filter=demo -``` - - - -You can now access the Demo app on [http://localhost:3002](http://localhost:3002). - -### Access Formbricks website - -If you want to make changes to the Formbricks website, e.g. to update the documentation, run the following command in a separate terminal window: - - - -```bash -pnpm dev --filter=formbricks-com -``` - - - -You can now access the Formbricks website on [http://localhost:3001](http://localhost:3001). +Still can’t figure it out? Join our [Discord](https://discord.com/invite/3YFcABF2Ts)! diff --git a/apps/formbricks-com/app/docs/contributing/troubleshooting/page.mdx b/apps/formbricks-com/app/docs/contributing/troubleshooting/page.mdx index 2e894fca0c..3b0aafe32b 100644 --- a/apps/formbricks-com/app/docs/contributing/troubleshooting/page.mdx +++ b/apps/formbricks-com/app/docs/contributing/troubleshooting/page.mdx @@ -4,7 +4,7 @@ import ClearAppData from "./clear-app-data.webp"; import UncaughtPromise from "./uncaught-promise.webp"; import Logout from "./logout.webp"; -export const meta = { +export const metadata = { title: "Formbricks Troubleshooting Guide: How to Solve & Debug Common Issues", description: "Facing issues with Formbricks? This troubleshooting guide covers frequently encountered problems, from Prisma migrations to package errors and more. Detailed solutions, accompanied by visual aids, ensure a smoother user experience with Formbricks", diff --git a/apps/formbricks-com/app/docs/faq/page.mdx b/apps/formbricks-com/app/docs/faq/page.mdx index 197cb5b704..4fc3b2a70e 100644 --- a/apps/formbricks-com/app/docs/faq/page.mdx +++ b/apps/formbricks-com/app/docs/faq/page.mdx @@ -1,6 +1,6 @@ import FAQ from "@/components/docs/docsFaq"; -export const meta = { +export const metadata = { title: "FAQ", description: "Frequently Asked Questions about Formbricks and how to use it.", }; diff --git a/apps/formbricks-com/app/docs/getting-started/framework-guides/page.mdx b/apps/formbricks-com/app/docs/getting-started/framework-guides/page.mdx index c459097415..9e2bf16d4b 100644 --- a/apps/formbricks-com/app/docs/getting-started/framework-guides/page.mdx +++ b/apps/formbricks-com/app/docs/getting-started/framework-guides/page.mdx @@ -13,7 +13,7 @@ export const metadata = { # Framework Guides -One can integrate Formbricks into their app using multipe options! Checkout the options below that we provide! If you are looking +One can integrate Formbricks into their app using multiple options! Checkout the options below that we provide! If you are looking for something else, please [join our Discord!](https://formbricks.com/discord) and we would be glad to help. {{ className: 'lead' }} @@ -24,7 +24,7 @@ for something else, please [join our Discord!](https://formbricks.com/discord) a Before getting started, make sure you have: -1. A web application in your desired framework set up and running. +1. A web application in your desired framework is set up and running. 2. A Formbricks account with access to your environment ID and API host. You can find these in the **Setup Checklist** in the Settings: ` tag to your HTML head, and that’s abou ```html {{ title: 'index.html' }} ``` @@ -64,7 +64,7 @@ All you need to do is copy a ``} ) : null}
diff --git a/apps/formbricks-com/components/home/Steps.tsx b/apps/formbricks-com/components/home/Steps.tsx index 7a86d4db06..fdd4f72657 100644 --- a/apps/formbricks-com/components/home/Steps.tsx +++ b/apps/formbricks-com/components/home/Steps.tsx @@ -1,10 +1,12 @@ import DemoPreview from "@/components/dummyUI/DemoPreview"; import DashboardMockupDark from "@/images/dashboard-mockup-dark.png"; import DashboardMockup from "@/images/dashboard-mockup.png"; -import { Button } from "@formbricks/ui/Button"; import { CursorArrowRaysIcon } from "@heroicons/react/24/solid"; import Image from "next/image"; import { useState } from "react"; + +import { Button } from "@formbricks/ui/Button"; + import AddEventDummy from "../dummyUI/AddEventDummy"; import AddNoCodeEventModalDummy from "../dummyUI/AddNoCodeEventModalDummy"; import HeadingCentered from "../shared/HeadingCentered"; @@ -43,7 +45,7 @@ export const Steps: React.FC = () => {
-
+

Step 2

-

+

No-Code: Track User Actions

@@ -74,7 +76,7 @@ export const Steps: React.FC = () => {

Step 3

-

+

Create your survey

@@ -82,7 +84,7 @@ export const Steps: React.FC = () => { adjust the look and feel of your survey.

-
+
@@ -91,14 +93,14 @@ export const Steps: React.FC = () => {
-
+

Step 4

-

+

Set segment and trigger

@@ -114,7 +116,7 @@ export const Steps: React.FC = () => {

Step 5

-

+

Make better decisions

diff --git a/apps/formbricks-com/components/home/VideoWalkThrough.tsx b/apps/formbricks-com/components/home/VideoWalkThrough.tsx index 8dec387a29..4c40872d68 100644 --- a/apps/formbricks-com/components/home/VideoWalkThrough.tsx +++ b/apps/formbricks-com/components/home/VideoWalkThrough.tsx @@ -1,5 +1,5 @@ -import { ResponsiveVideo } from "@formbricks/ui/ResponsiveVideo"; import { Modal } from "@formbricks/ui/Modal"; +import { ResponsiveVideo } from "@formbricks/ui/ResponsiveVideo"; interface VideoWalkThroughProps { open: boolean; diff --git a/apps/formbricks-com/components/shared/AuthorBox.tsx b/apps/formbricks-com/components/shared/AuthorBox.tsx index f818fe9159..86312446d3 100644 --- a/apps/formbricks-com/components/shared/AuthorBox.tsx +++ b/apps/formbricks-com/components/shared/AuthorBox.tsx @@ -1,19 +1,21 @@ -import Image from "next/image"; import AuthorJohannes from "@/images/blog/johannes-co-founder-formbricks-small.jpg"; +import AuthorOla from "@/images/blog/ola-content-writer.jpg"; +import Image from "next/image"; interface AuthorBoxProps { name: string; title: string; date: string; duration: string; + author: string; } -export default function AuthorBox({ name, title, date, duration }: AuthorBoxProps) { +export default function AuthorBox({ name, title, date, duration, author }: AuthorBoxProps) { return (

{name}
-

{name}

-

{title}

+

{name}

+

{title}

-

{duration} Minutes

-

{date}

+

{duration} Minutes

+

{date}

diff --git a/apps/formbricks-com/components/shared/BestPracticeNavigation.tsx b/apps/formbricks-com/components/shared/BestPracticeNavigation.tsx index d774213730..636d938871 100644 --- a/apps/formbricks-com/components/shared/BestPracticeNavigation.tsx +++ b/apps/formbricks-com/components/shared/BestPracticeNavigation.tsx @@ -1,3 +1,6 @@ +import clsx from "clsx"; +import Link from "next/link"; + import { BaseballIcon, CancelSubscriptionIcon, @@ -8,8 +11,6 @@ import { OnboardingIcon, PMFIcon, } from "@formbricks/ui/icons"; -import clsx from "clsx"; -import Link from "next/link"; export default function BestPracticeNavigation() { const BestPractices = [ @@ -83,8 +84,8 @@ export default function BestPracticeNavigation() { return (
{BestPractices.map((bestPractice) => ( - -
+ +
{bestPractice.name} -

{bestPractice.description}

+

+ {bestPractice.description} +

))} diff --git a/apps/formbricks-com/components/shared/BestPractices.tsx b/apps/formbricks-com/components/shared/BestPractices.tsx index fb0567c1b8..756cf3883c 100644 --- a/apps/formbricks-com/components/shared/BestPractices.tsx +++ b/apps/formbricks-com/components/shared/BestPractices.tsx @@ -1,37 +1,21 @@ -import { Button } from "@formbricks/ui/Button"; -import { usePlausible } from "next-plausible"; -import { useRouter } from "next/router"; import BestPracticeNavigation from "./BestPracticeNavigation"; export default function InsightOppos() { - const plausible = usePlausible(); - const router = useRouter(); return (
-

+

Get started with{" "} Best Practices

-

+

Run battle-tested approaches for qualitative user research in minutes.

- -
- -
); } diff --git a/apps/formbricks-com/components/shared/BreakerCTA.tsx b/apps/formbricks-com/components/shared/BreakerCTA.tsx index 2aa5f2a4f9..7e58507137 100644 --- a/apps/formbricks-com/components/shared/BreakerCTA.tsx +++ b/apps/formbricks-com/components/shared/BreakerCTA.tsx @@ -1,8 +1,9 @@ -import { Button } from "@formbricks/ui/Button"; import clsx from "clsx"; import { usePlausible } from "next-plausible"; import { useRouter } from "next/router"; +import { Button } from "@formbricks/ui/Button"; + interface Props { teaser: string; headline: string; diff --git a/apps/formbricks-com/components/shared/CTA.tsx b/apps/formbricks-com/components/shared/CTA.tsx index 63a9e6aebd..7b3439152a 100644 --- a/apps/formbricks-com/components/shared/CTA.tsx +++ b/apps/formbricks-com/components/shared/CTA.tsx @@ -1,5 +1,7 @@ -import { Button } from "@formbricks/ui/Button"; import { useRouter } from "next/router"; + +import { Button } from "@formbricks/ui/Button"; + import HeadingCentered from "./HeadingCentered"; export default function CTA() { @@ -10,7 +12,7 @@ export default function CTA() {
-
+

Self-hosted

Run locally e.g. with docker-compose.

@@ -392,7 +391,7 @@ export default function Header() {
)} - Concierge + Community Pricing Docs Blog diff --git a/apps/formbricks-com/components/shared/HeadingCentered.tsx b/apps/formbricks-com/components/shared/HeadingCentered.tsx index d7c74aef8e..4d2b51bb67 100644 --- a/apps/formbricks-com/components/shared/HeadingCentered.tsx +++ b/apps/formbricks-com/components/shared/HeadingCentered.tsx @@ -13,10 +13,10 @@ export default function HeadingCentered({ teaser, heading, subheading, closer }:

{teaser}

-

+

{heading}

-

+

{subheading}

diff --git a/apps/formbricks-com/components/shared/HeroAnimation.tsx b/apps/formbricks-com/components/shared/HeroAnimation.tsx index 8556664838..bfbd3d6c9e 100644 --- a/apps/formbricks-com/components/shared/HeroAnimation.tsx +++ b/apps/formbricks-com/components/shared/HeroAnimation.tsx @@ -1,5 +1,5 @@ -import { useEffect, useRef, useState } from "react"; import type { LottiePlayer } from "lottie-web"; +import { useEffect, useRef, useState } from "react"; export default function HeroAnimation(props: any) { const ref = useRef(null); diff --git a/apps/formbricks-com/components/shared/HeroTitle.tsx b/apps/formbricks-com/components/shared/HeroTitle.tsx index 6f6c26177f..f71ada2198 100644 --- a/apps/formbricks-com/components/shared/HeroTitle.tsx +++ b/apps/formbricks-com/components/shared/HeroTitle.tsx @@ -9,14 +9,14 @@ interface Props { export default function HeroTitle({ headingPt1, headingTeal, headingPt2, subheading, children }: Props) { return (
-

+

{headingPt1}{" "} {headingTeal} {" "} {headingPt2}

-

+

{subheading}

{children}
diff --git a/apps/formbricks-com/components/shared/LayoutMdx.tsx b/apps/formbricks-com/components/shared/LayoutMdx.tsx index c1015057eb..50a2ba02f5 100644 --- a/apps/formbricks-com/components/shared/LayoutMdx.tsx +++ b/apps/formbricks-com/components/shared/LayoutMdx.tsx @@ -1,5 +1,6 @@ import SlideInBanner from "@/components/shared/SlideInBanner"; import { useEffect } from "react"; + import Footer from "./Footer"; import Header from "./Header"; import MetaInformation from "./MetaInformation"; @@ -59,7 +60,7 @@ export default function LayoutMdx({ meta, children }: Props) { )} )} - + {children} diff --git a/apps/formbricks-com/components/shared/Logo.tsx b/apps/formbricks-com/components/shared/Logo.tsx index 3f45009ea6..8c80f4bac5 100644 --- a/apps/formbricks-com/components/shared/Logo.tsx +++ b/apps/formbricks-com/components/shared/Logo.tsx @@ -1,9 +1,9 @@ -import Image from "next/image"; -import logomark from "@/images/logo/logomark.svg"; +import footerLogoDark from "@/images/logo/footerlogo-dark.svg"; +import footerLogo from "@/images/logo/footerlogo.svg"; import logo from "@/images/logo/logo.svg"; import logoDark from "@/images/logo/logo_dark.svg"; -import footerLogo from "@/images/logo/footerlogo.svg"; -import footerLogoDark from "@/images/logo/footerlogo-dark.svg"; +import logomark from "@/images/logo/logomark.svg"; +import Image from "next/image"; export function Logomark(props: any) { return Formbricks Open source Forms & Surveys Logomark; diff --git a/apps/formbricks-com/components/shared/MdxCTA.tsx b/apps/formbricks-com/components/shared/MdxCTA.tsx index 2ae624f825..8764b47541 100644 --- a/apps/formbricks-com/components/shared/MdxCTA.tsx +++ b/apps/formbricks-com/components/shared/MdxCTA.tsx @@ -1,6 +1,7 @@ -import { Button } from "@formbricks/ui/Button"; import { useRouter } from "next/router"; +import { Button } from "@formbricks/ui/Button"; + export default function CTA() { const router = useRouter(); return ( @@ -13,7 +14,7 @@ export default function CTA() { Try Formbricks right now!

-
+

Self-hosted

Run locally with docker-compose.

- - {tier.name == "Cloud Pro" && ( -

No Creditcard required.

- )} - {tier.name == "Cloud" && ( -

Free forever 🤍

- )} -
-
- ))} -
-
-
- ); -} diff --git a/apps/formbricks-com/components/shared/PricingCalculator.tsx b/apps/formbricks-com/components/shared/PricingCalculator.tsx index 03972ac30f..cc4ca82ef2 100644 --- a/apps/formbricks-com/components/shared/PricingCalculator.tsx +++ b/apps/formbricks-com/components/shared/PricingCalculator.tsx @@ -1,16 +1,16 @@ import { Slider } from "@/components/shared/Slider"; import { useState } from "react"; -const ProductItem = ({ label, usersCount, price, onSliderChange }) => ( +const LinkSurveySlider = ({ label, usersCount, price, onSliderChange }) => (
{label}
- {Math.round(usersCount).toLocaleString()} MTU + {Math.round(usersCount).toLocaleString()} Submissions
-
+
${price.toFixed(2)}
@@ -34,10 +34,76 @@ const ProductItem = ({ label, usersCount, price, onSliderChange }) => (
); +const InAppSlider = ({ label, usersCount, price, onSliderChange }) => ( +
+
+
+ {label} +
+
+ {Math.round(usersCount).toLocaleString()} Submissions +
+
+ ${price.toFixed(2)} +
+
+
+ +
+ {[3, 4, 5, 6].map((mark) => ( + + {mark === 3 ? "1K" : mark === 4 ? "10K" : mark === 5 ? "100K" : "1M"} + + ))} +
+
+
+); + +const UserSegmentationSlider = ({ label, usersCount, price, onSliderChange }) => ( +
+
+
+ {label} +
+
+ {Math.round(usersCount).toLocaleString()} Submissions +
+
+ ${price.toFixed(2)} +
+
+
+ +
+ {[3, 4, 5, 6].map((mark) => ( + + {mark === 3 ? "1K" : mark === 4 ? "10K" : mark === 5 ? "100K" : "1M"} + + ))} +
+
+
+); + const Headers = () => (
-

Product

-

+

Product

+

Subtotal

@@ -45,14 +111,14 @@ const Headers = () => ( const MonthlyEstimate = ({ price }) => (
- + Monthly estimate:
- + ${price.toFixed(2)} - + {" "} / month @@ -79,26 +145,17 @@ export const PricingCalculator = () => { return (
-

+

Pricing Calculator

-
+

- setInProductSlider(value[0])} - /> - -
- - {
+ setLinkSlider(value[0])} + /> + +
+ + setInProductSlider(value[0])} + /> +
diff --git a/apps/formbricks-com/components/shared/PricingGetStarted.tsx b/apps/formbricks-com/components/shared/PricingGetStarted.tsx index 72b012b9ff..535792c8e1 100644 --- a/apps/formbricks-com/components/shared/PricingGetStarted.tsx +++ b/apps/formbricks-com/components/shared/PricingGetStarted.tsx @@ -9,7 +9,7 @@ export const GetStartedWithPricing = ({ showDetailed }: { showDetailed: boolean

Free

{showDetailed && ( -

+

General free usage on every product. Best for early stage startups and hobbyists

)} @@ -26,7 +26,7 @@ export const GetStartedWithPricing = ({ showDetailed }: { showDetailed: boolean

Paid

{showDetailed && ( -

+

Formbricks with the next-generation features, Pay only for the tracked users.

)} diff --git a/apps/formbricks-com/components/shared/PricingTable.tsx b/apps/formbricks-com/components/shared/PricingTable.tsx index b27db1e32e..0b1d430666 100644 --- a/apps/formbricks-com/components/shared/PricingTable.tsx +++ b/apps/formbricks-com/components/shared/PricingTable.tsx @@ -1,36 +1,43 @@ -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@formbricks/ui/Tooltip"; import { CheckIcon, XMarkIcon } from "@heroicons/react/24/outline"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@formbricks/ui/Tooltip"; + export const PricingTable = ({ leadRow, pricing, endRow }) => { return (
-
+
{leadRow.title} + {leadRow.comparison}
+ text-slate-500 md:text-lg dark:text-slate-200"> {leadRow.free}
-
+
{leadRow.paid}
-
+
{pricing.map((feature) => (
-
+
{feature.name} {feature.addOnText && ( - + Addon )} + {feature.comingSoon && ( + + coming soon + + )}
{feature.addOnText ? ( @@ -86,14 +93,14 @@ export const PricingTable = ({ leadRow, pricing, endRow }) => {
-
+
{endRow.title}
-
+
{endRow.free}
-
+
{endRow.paid}
diff --git a/apps/formbricks-com/components/shared/Search.tsx b/apps/formbricks-com/components/shared/Search.tsx index 679185b068..4241bbc526 100644 --- a/apps/formbricks-com/components/shared/Search.tsx +++ b/apps/formbricks-com/components/shared/Search.tsx @@ -1,8 +1,8 @@ -import { useCallback, useEffect, useState } from "react"; -import { createPortal } from "react-dom"; +import { DocSearchModal, useDocSearchKeyboardEvents } from "@docsearch/react"; import Link from "next/link"; import Router from "next/router"; -import { DocSearchModal, useDocSearchKeyboardEvents } from "@docsearch/react"; +import { useCallback, useEffect, useState } from "react"; +import { createPortal } from "react-dom"; const docSearchConfig = { appId: process.env.NEXT_PUBLIC_DOCSEARCH_APP_ID || "", @@ -49,14 +49,14 @@ export function Search() { <> +
+ )} +
+
+ ); +} diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/FileUploadSummary.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/FileUploadSummary.tsx new file mode 100644 index 0000000000..29c62d9bc4 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/FileUploadSummary.tsx @@ -0,0 +1,121 @@ +import Headline from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/Headline"; +import { questionTypes } from "@/app/lib/questions"; +import { InboxStackIcon } from "@heroicons/react/24/solid"; +import { DownloadIcon, FileIcon } from "lucide-react"; +import Link from "next/link"; + +import { getPersonIdentifier } from "@formbricks/lib/person/util"; +import { timeSince } from "@formbricks/lib/time"; +import type { TSurveyQuestionSummary } from "@formbricks/types/surveys"; +import { TSurveyFileUploadQuestion } from "@formbricks/types/surveys"; +import { PersonAvatar } from "@formbricks/ui/Avatars"; + +interface FileUploadSummaryProps { + questionSummary: TSurveyQuestionSummary; + environmentId: string; +} + +export default function FileUploadSummary({ questionSummary, environmentId }: FileUploadSummaryProps) { + const questionTypeInfo = questionTypes.find((type) => type.id === questionSummary.question.type); + + return ( +
+
+ + +
+
+ {questionTypeInfo && } + {questionTypeInfo ? questionTypeInfo.label : "Unknown Question Type"} Question +
+
+ + {questionSummary.responses.length} Responses +
+ {!questionSummary.question.required && ( +
Optional
+ )} +
+
+
+
+
User
+
Response
+
Time
+
+ {questionSummary.responses.map((response) => { + const displayIdentifier = response.person ? getPersonIdentifier(response.person) : null; + + return ( +
+
+ {response.person ? ( + +
+ +
+

+ {displayIdentifier} +

+ + ) : ( +
+
+ +
+

Anonymous

+
+ )} +
+ +
+ {response.value === "skipped" && ( +
+

skipped

+
+ )} + + {Array.isArray(response.value) && + (response.value.length > 0 ? ( + response.value.map((fileUrl, index) => ( +
+ +
+
+ +
+
+
+ +
+ +

+ {fileUrl.split("/").pop()} +

+
+
+ )) + ) : ( +
+

skipped

+
+ ))} +
+ +
{timeSince(response.updatedAt.toISOString())}
+
+ ); + })} +
+
+ ); +} diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/Headline.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/Headline.tsx index 4fde567053..9040e14768 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/Headline.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/Headline.tsx @@ -1,17 +1,11 @@ interface HeadlineProps { headline: string; - required?: boolean; } -export default function Headline({ headline, required = true }: HeadlineProps) { +export default function Headline({ headline }: HeadlineProps) { return (

{headline}

- {!required && ( - - Optional - - )}
); } diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/HiddenFieldsSummary.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/HiddenFieldsSummary.tsx index 6f76a361f8..77750b13fa 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/HiddenFieldsSummary.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/HiddenFieldsSummary.tsx @@ -1,14 +1,15 @@ import Headline from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/Headline"; -import { getPersonIdentifier } from "@formbricks/lib/people/helpers"; -import { timeSince } from "@formbricks/lib/time"; -import { TEnvironment } from "@formbricks/types/v1/environment"; -import { TResponse } from "@formbricks/types/v1/responses"; -import { TSurvey } from "@formbricks/types/v1/surveys"; -import { PersonAvatar } from "@formbricks/ui/Avatars"; import { ChatBubbleBottomCenterTextIcon, InboxStackIcon } from "@heroicons/react/24/solid"; import { Link } from "lucide-react"; import { FC, useMemo } from "react"; +import { getPersonIdentifier } from "@formbricks/lib/person/util"; +import { timeSince } from "@formbricks/lib/time"; +import { TEnvironment } from "@formbricks/types/environment"; +import { TResponse } from "@formbricks/types/responses"; +import { TSurvey } from "@formbricks/types/surveys"; +import { PersonAvatar } from "@formbricks/ui/Avatars"; + interface HiddenFieldsSummaryProps { question: string; survey: TSurvey; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/LinkModalButton.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/LinkModalButton.tsx index 2ca1039f9a..450db8739d 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/LinkModalButton.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/LinkModalButton.tsx @@ -1,62 +1,132 @@ "use client"; -import { TSurvey } from "@formbricks/types/v1/surveys"; -import { Button } from "@formbricks/ui/Button"; -import { ShareIcon } from "@heroicons/react/24/outline"; +import { + deleteResultShareUrlAction, + generateResultShareUrlAction, + getResultShareUrlAction, +} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/actions"; +import { LinkIcon } from "@heroicons/react/24/outline"; +import { DownloadIcon } from "lucide-react"; import { useState } from "react"; -import clsx from "clsx"; -import { TProduct } from "@formbricks/types/v1/product"; -import ShareEmbedSurvey from "./ShareEmbedSurvey"; -import { TProfile } from "@formbricks/types/v1/profile"; +import { useEffect } from "react"; +import toast from "react-hot-toast"; -interface LinkSurveyShareButtonProps { +import { TProduct } from "@formbricks/types/product"; +import { TSurvey } from "@formbricks/types/surveys"; +import { TUser } from "@formbricks/types/user"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@formbricks/ui/DropdownMenu"; + +import ShareEmbedSurvey from "./ShareEmbedSurvey"; +import ShareSurveyResults from "./ShareSurveyResults"; + +interface SurveyShareButtonProps { survey: TSurvey; className?: string; - surveyBaseUrl: string; + webAppUrl: string; product: TProduct; - profile: TProfile; + user: TUser; } -export default function LinkSurveyShareButton({ - survey, - className, - surveyBaseUrl, - product, - profile, -}: LinkSurveyShareButtonProps) { +export default function SurveyShareButton({ survey, webAppUrl, product, user }: SurveyShareButtonProps) { const [showLinkModal, setShowLinkModal] = useState(false); - const isSingleUse = survey.singleUse?.enabled ?? false; + const [showResultsLinkModal, setShowResultsLinkModal] = useState(false); + + const [showPublishModal, setShowPublishModal] = useState(false); + const [surveyUrl, setSurveyUrl] = useState(""); + + const handlePublish = async () => { + const key = await generateResultShareUrlAction(survey.id); + setSurveyUrl(webAppUrl + "/share/" + key); + setShowPublishModal(true); + }; + + const handleUnpublish = () => { + deleteResultShareUrlAction(survey.id) + .then(() => { + toast.success("Survey Unpublished successfully"); + setShowPublishModal(false); + setShowLinkModal(false); + }) + .catch((error) => { + toast.error(`Error: ${error.message}`); + }); + }; + + useEffect(() => { + async function fetchSharingKey() { + const sharingKey = await getResultShareUrlAction(survey.id); + if (sharingKey) { + setSurveyUrl(webAppUrl + "/share/" + sharingKey); + setShowPublishModal(true); + } + } + + fetchSharingKey(); + }, [survey.id, webAppUrl]); + + useEffect(() => { + if (showResultsLinkModal) { + setShowLinkModal(false); + } + }, [showResultsLinkModal]); return ( <> - - {showLinkModal && isSingleUse ? ( + + +
+
+ Share + +
+ +
+
+ + {survey.type === "link" && ( + { + setShowLinkModal(true); + }}> +

Share Survey

+
+ )} + { + setShowResultsLinkModal(true); + }}> +

Publish Results

+
+
+
+ + {showLinkModal && ( - ) : ( - )} diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/LinkSingleUseSurveyModal.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/LinkSingleUseSurveyModal.tsx index f95115f7f8..4492291fba 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/LinkSingleUseSurveyModal.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/LinkSingleUseSurveyModal.tsx @@ -1,15 +1,16 @@ "use client"; import { generateSingleUseIdAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/actions"; -import { truncateMiddle } from "@/app/lib/utils"; -import { cn } from "@formbricks/lib/cn"; -import { TSurvey } from "@formbricks/types/v1/surveys"; -import { Button } from "@formbricks/ui/Button"; import { ArrowPathIcon } from "@heroicons/react/24/outline"; import { DocumentDuplicateIcon, EyeIcon } from "@heroicons/react/24/solid"; import { useEffect, useRef, useState } from "react"; import toast from "react-hot-toast"; +import { cn } from "@formbricks/lib/cn"; +import { truncateMiddle } from "@formbricks/lib/strings"; +import { TSurvey } from "@formbricks/types/surveys"; +import { Button } from "@formbricks/ui/Button"; + interface LinkSingleUseSurveyModalProps { survey: TSurvey; surveyBaseUrl: string; @@ -20,6 +21,8 @@ export default function LinkSingleUseSurveyModal({ survey, surveyBaseUrl }: Link useEffect(() => { fetchSingleUseIds(); + + // eslint-disable-next-line react-hooks/exhaustive-deps }, [survey.singleUse?.isEncrypted]); const fetchSingleUseIds = async () => { @@ -30,12 +33,11 @@ export default function LinkSingleUseSurveyModal({ survey, surveyBaseUrl }: Link const generateSingleUseIds = async (isEncrypted: boolean) => { const promises = Array(7) .fill(null) - .map(() => generateSingleUseIdAction(isEncrypted)); - const ids = await Promise.all(promises); - return ids; + .map(() => generateSingleUseIdAction(survey.id, isEncrypted)); + return await Promise.all(promises); }; - const defaultSurveyUrl = `${surveyBaseUrl}/${survey.id}`; + const defaultSurveyUrl = `${surveyBaseUrl}/s/${survey.id}`; const [selectedSingleUseIds, setSelectedSingleIds] = useState([]); const linkTextRef = useRef(null); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/MultipleChoiceSummary.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/MultipleChoiceSummary.tsx index 4c8459c0d4..5f54038f8a 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/MultipleChoiceSummary.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/MultipleChoiceSummary.tsx @@ -1,22 +1,27 @@ -import { QuestionType } from "@formbricks/types/questions"; -import type { QuestionSummary } from "@formbricks/types/responses"; -import { ProgressBar } from "@formbricks/ui/ProgressBar"; -import { PersonAvatar } from "@formbricks/ui/Avatars"; +import Headline from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/Headline"; +import { questionTypes } from "@/app/lib/questions"; import { InboxStackIcon } from "@heroicons/react/24/solid"; -import { useMemo } from "react"; import Link from "next/link"; -import { getPersonIdentifier } from "@formbricks/lib/people/helpers"; +import { useMemo } from "react"; +import { useState } from "react"; + +import { getPersonIdentifier } from "@formbricks/lib/person/util"; +import { TSurveyQuestionType } from "@formbricks/types/surveys"; +import type { TSurveyQuestionSummary } from "@formbricks/types/surveys"; import { TSurveyMultipleChoiceMultiQuestion, TSurveyMultipleChoiceSingleQuestion, -} from "@formbricks/types/v1/surveys"; -import { questionTypes } from "@/app/lib/questions"; -import Headline from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/Headline"; +} from "@formbricks/types/surveys"; +import { PersonAvatar } from "@formbricks/ui/Avatars"; +import { ProgressBar } from "@formbricks/ui/ProgressBar"; interface MultipleChoiceSummaryProps { - questionSummary: QuestionSummary; + questionSummary: TSurveyQuestionSummary< + TSurveyMultipleChoiceMultiQuestion | TSurveyMultipleChoiceSingleQuestion + >; environmentId: string; surveyType: string; + responsesPerPage: number; } interface ChoiceResult { @@ -38,9 +43,10 @@ export default function MultipleChoiceSummary({ questionSummary, environmentId, surveyType, + responsesPerPage, }: MultipleChoiceSummaryProps) { - const isSingleChoice = questionSummary.question.type === QuestionType.MultipleChoiceSingle; - + const isSingleChoice = questionSummary.question.type === TSurveyQuestionType.MultipleChoiceSingle; + const [otherDisplayCount, setOtherDisplayCount] = useState(responsesPerPage); const questionTypeInfo = questionTypes.find((type) => type.id === questionSummary.question.type); const results: ChoiceResult[] = useMemo(() => { @@ -125,7 +131,7 @@ export default function MultipleChoiceSummary({ return (
- +
@@ -136,6 +142,9 @@ export default function MultipleChoiceSummary({ {totalResponses} responses
+ {!questionSummary.question.required && ( +
Optional
+ )} {/*
2.8 average @@ -169,6 +178,7 @@ export default function MultipleChoiceSummary({
{result.otherValues .filter((otherValue) => otherValue !== "") + .slice(0, otherDisplayCount) .map((otherValue, idx) => (
{surveyType === "link" && ( @@ -198,6 +208,15 @@ export default function MultipleChoiceSummary({ )}
))} + {otherDisplayCount < result.otherValues.length && ( +
+ +
+ )}
)}
diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/NPSSummary.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/NPSSummary.tsx index 0f80c361e5..1f15037ddb 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/NPSSummary.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/NPSSummary.tsx @@ -1,13 +1,14 @@ import Headline from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/Headline"; -import type { QuestionSummary } from "@formbricks/types/responses"; -import { TSurveyNPSQuestion } from "@formbricks/types/v1/surveys"; -import { ProgressBar, HalfCircle } from "@formbricks/ui/ProgressBar"; +import { questionTypes } from "@/app/lib/questions"; import { InboxStackIcon } from "@heroicons/react/24/solid"; import { useMemo } from "react"; -import { questionTypes } from "@/app/lib/questions"; + +import type { TSurveyQuestionSummary } from "@formbricks/types/surveys"; +import { TSurveyNPSQuestion } from "@formbricks/types/surveys"; +import { HalfCircle, ProgressBar } from "@formbricks/ui/ProgressBar"; interface NPSSummaryProps { - questionSummary: QuestionSummary; + questionSummary: TSurveyQuestionSummary; } interface Result { @@ -79,7 +80,7 @@ export default function NPSSummary({ questionSummary }: NPSSummaryProps) { return (
- +
@@ -90,6 +91,9 @@ export default function NPSSummary({ questionSummary }: NPSSummaryProps) { {result.total} responses
+ {!questionSummary.question.required && ( +
Optional
+ )}
diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/OpenTextSummary.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/OpenTextSummary.tsx index 619ae25615..b558b5876f 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/OpenTextSummary.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/OpenTextSummary.tsx @@ -1,26 +1,33 @@ -import { getPersonIdentifier } from "@formbricks/lib/people/helpers"; import Headline from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/Headline"; -import { timeSince } from "@formbricks/lib/time"; -import type { QuestionSummary } from "@formbricks/types/responses"; -import { TSurveyOpenTextQuestion } from "@formbricks/types/v1/surveys"; -import { PersonAvatar } from "@formbricks/ui/Avatars"; +import { questionTypes } from "@/app/lib/questions"; import { InboxStackIcon } from "@heroicons/react/24/solid"; import Link from "next/link"; -import { questionTypes } from "@/app/lib/questions"; +import React, { useState } from "react"; + +import { getPersonIdentifier } from "@formbricks/lib/person/util"; +import { timeSince } from "@formbricks/lib/time"; +import type { TSurveyQuestionSummary } from "@formbricks/types/surveys"; +import { TSurveyOpenTextQuestion } from "@formbricks/types/surveys"; +import { PersonAvatar } from "@formbricks/ui/Avatars"; interface OpenTextSummaryProps { - questionSummary: QuestionSummary; + questionSummary: TSurveyQuestionSummary; environmentId: string; + responsesPerPage: number; } -export default function OpenTextSummary({ questionSummary, environmentId }: OpenTextSummaryProps) { +export default function OpenTextSummary({ + questionSummary, + environmentId, + responsesPerPage, +}: OpenTextSummaryProps) { const questionTypeInfo = questionTypes.find((type) => type.id === questionSummary.question.type); + const [displayCount, setDisplayCount] = useState(responsesPerPage); return (
- - +
{questionTypeInfo && } @@ -30,6 +37,9 @@ export default function OpenTextSummary({ questionSummary, environmentId }: Open {questionSummary.responses.length} Responses
+ {!questionSummary.question.required && ( +
Optional
+ )}
@@ -38,12 +48,12 @@ export default function OpenTextSummary({ questionSummary, environmentId }: Open
Response
Time
- {questionSummary.responses.map((response) => { + {questionSummary.responses.slice(0, displayCount).map((response) => { const displayIdentifier = getPersonIdentifier(response.person!); return (
+ className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 md:text-base">
{response.person ? ( ); })} + + {displayCount < questionSummary.responses.length && ( +
+ +
+ )}
); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/PictureChoiceSummary.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/PictureChoiceSummary.tsx new file mode 100644 index 0000000000..d4410c1c73 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/PictureChoiceSummary.tsx @@ -0,0 +1,126 @@ +import Headline from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/Headline"; +import { questionTypes } from "@/app/lib/questions"; +import { InboxStackIcon } from "@heroicons/react/24/solid"; +import Image from "next/image"; +import { useMemo } from "react"; + +import type { TSurveyPictureSelectionQuestion, TSurveyQuestionSummary } from "@formbricks/types/surveys"; +import { ProgressBar } from "@formbricks/ui/ProgressBar"; + +interface PictureChoiceSummaryProps { + questionSummary: TSurveyQuestionSummary; +} + +interface ChoiceResult { + id: string; + imageUrl: string; + count: number; + percentage?: number; +} + +export default function PictureChoiceSummary({ questionSummary }: PictureChoiceSummaryProps) { + const isMulti = questionSummary.question.allowMulti; + const questionTypeInfo = questionTypes.find((type) => type.id === questionSummary.question.type); + + const results: ChoiceResult[] = useMemo(() => { + if (!("choices" in questionSummary.question)) return []; + + // build a dictionary of choices + const resultsDict: { [key: string]: ChoiceResult } = {}; + for (const choice of questionSummary.question.choices) { + resultsDict[choice.id] = { + id: choice.id, + imageUrl: choice.imageUrl, + count: 0, + percentage: 0, + }; + } + + // count the responses + for (const response of questionSummary.responses) { + if (Array.isArray(response.value)) { + for (const choice of response.value) { + if (choice in resultsDict) { + resultsDict[choice].count += 1; + } + } + } + } + + // add the percentage + const total = questionSummary.responses.length; + for (const key of Object.keys(resultsDict)) { + if (resultsDict[key].count) { + resultsDict[key].percentage = resultsDict[key].count / total; + } + } + + // sort by count and transform to array + const results = Object.values(resultsDict).sort((a, b) => { + return b.count - a.count; + }); + + return results; + }, [questionSummary]); + + const totalResponses = useMemo(() => { + let total = 0; + for (const result of results) { + total += result.count; + } + return total; + }, [results]); + + return ( +
+
+ + +
+
+ {questionTypeInfo && } + {questionTypeInfo ? questionTypeInfo.label : "Unknown Question Type"} Question +
+
+ + {totalResponses} responses +
+
+ {isMulti ? "Multi" : "Single"} Select +
+ {!questionSummary.question.required && ( +
Optional
+ )} +
+
+
+ {results.map((result) => ( +
+
+
+
+ choice-image +
+
+

+ {Math.round((result.percentage || 0) * 100)}% +

+
+
+

+ {result.count} {result.count === 1 ? "response" : "responses"} +

+
+ +
+ ))} +
+
+ ); +} diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/RatingSummary.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/RatingSummary.tsx index 1a9dbf126e..cde299e7bd 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/RatingSummary.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/RatingSummary.tsx @@ -1,15 +1,16 @@ -import type { QuestionSummary } from "@formbricks/types/responses"; -import { ProgressBar } from "@formbricks/ui/ProgressBar"; +import Headline from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/Headline"; +import { questionTypes } from "@/app/lib/questions"; import { InboxStackIcon } from "@heroicons/react/24/solid"; import { useMemo } from "react"; -import { QuestionType } from "@formbricks/types/questions"; -import { TSurveyRatingQuestion } from "@formbricks/types/v1/surveys"; + +import type { TSurveyQuestionSummary } from "@formbricks/types/surveys"; +import { TSurveyQuestionType } from "@formbricks/types/surveys"; +import { TSurveyRatingQuestion } from "@formbricks/types/surveys"; +import { ProgressBar } from "@formbricks/ui/ProgressBar"; import { RatingResponse } from "@formbricks/ui/RatingResponse"; -import { questionTypes } from "@/app/lib/questions"; -import Headline from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/Headline"; interface RatingSummaryProps { - questionSummary: QuestionSummary; + questionSummary: TSurveyQuestionSummary; } interface ChoiceResult { @@ -22,7 +23,7 @@ export default function RatingSummary({ questionSummary }: RatingSummaryProps) { const questionTypeInfo = questionTypes.find((type) => type.id === questionSummary.question.type); const results: ChoiceResult[] = useMemo(() => { - if (questionSummary.question.type !== QuestionType.Rating) return []; + if (questionSummary.question.type !== TSurveyQuestionType.Rating) return []; // build a dictionary of choices const resultsDict: { [key: string]: ChoiceResult } = {}; for (let i = 1; i <= questionSummary.question.range; i++) { @@ -81,7 +82,7 @@ export default function RatingSummary({ questionSummary }: RatingSummaryProps) { return (
- +
@@ -92,9 +93,12 @@ export default function RatingSummary({ questionSummary }: RatingSummaryProps) { {totalResponses} responses
+ {!questionSummary.question.required && ( +
Optional
+ )}
-
+
{results.map((result: any) => (
diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ShareEmbedSurvey.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ShareEmbedSurvey.tsx index 6835a79451..f33160b7eb 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ShareEmbedSurvey.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ShareEmbedSurvey.tsx @@ -1,37 +1,39 @@ "use client"; -import LinkTab from "./shareEmbedTabs/LinkTab"; -import EmailTab from "./shareEmbedTabs/EmailTab"; -import WebpageTab from "./shareEmbedTabs/WebpageTab"; import LinkSingleUseSurveyModal from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/LinkSingleUseSurveyModal"; +import { CodeBracketIcon, EnvelopeIcon, LinkIcon } from "@heroicons/react/24/outline"; import { useMemo, useState } from "react"; -import { TProduct } from "@formbricks/types/v1/product"; -import { TSurvey } from "@formbricks/types/v1/surveys"; + import { cn } from "@formbricks/lib/cn"; -import { DialogContent, Dialog } from "@formbricks/ui/Dialog"; +import { TProduct } from "@formbricks/types/product"; +import { TSurvey } from "@formbricks/types/surveys"; +import { TUser } from "@formbricks/types/user"; import { Button } from "@formbricks/ui/Button"; -import { LinkIcon, EnvelopeIcon, CodeBracketIcon } from "@heroicons/react/24/outline"; -import { TProfile } from "@formbricks/types/v1/profile"; +import { Dialog, DialogContent } from "@formbricks/ui/Dialog"; + +import EmailTab from "./shareEmbedTabs/EmailTab"; +import LinkTab from "./shareEmbedTabs/LinkTab"; +import WebpageTab from "./shareEmbedTabs/WebpageTab"; interface ShareEmbedSurveyProps { survey: TSurvey; open: boolean; setOpen: React.Dispatch>; - surveyBaseUrl: string; + webAppUrl: string; product: TProduct; - profile: TProfile; + user: TUser; } export default function ShareEmbedSurvey({ survey, open, setOpen, - surveyBaseUrl, + webAppUrl, product, - profile, + user, }: ShareEmbedSurveyProps) { - const surveyUrl = useMemo(() => surveyBaseUrl + survey.id, [survey]); + const surveyUrl = useMemo(() => webAppUrl + "/s/" + survey.id, [survey]); const isSingleUseLinkSurvey = survey.singleUse?.enabled; - const { email } = profile; + const { email } = user; const { brandColor } = product; const surveyBrandColor = survey.productOverwrites?.brandColor || brandColor; @@ -43,16 +45,6 @@ export default function ShareEmbedSurvey({ const [activeId, setActiveId] = useState(tabs[0].id); - const componentMap = { - link: isSingleUseLinkSurvey ? ( - - ) : ( - - ), - email: , - webpage: , - }; - return (
- {componentMap[activeId]} + {isSingleUseLinkSurvey ? ( + + ) : activeId === "link" ? ( + + ) : activeId === "email" ? ( + + ) : activeId === "webpage" ? ( + + ) : null}
{tabs.slice(0, 2).map((tab) => ( diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ShareSurveyResults.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ShareSurveyResults.tsx new file mode 100644 index 0000000000..5c97d1da61 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ShareSurveyResults.tsx @@ -0,0 +1,108 @@ +"use client"; + +import { CheckCircleIcon, GlobeEuropeAfricaIcon } from "@heroicons/react/24/solid"; +import { Clipboard } from "lucide-react"; +import { toast } from "react-hot-toast"; + +import { Button } from "@formbricks/ui/Button"; +import { Dialog, DialogContent } from "@formbricks/ui/Dialog"; + +interface ShareEmbedSurveyProps { + open: boolean; + setOpen: React.Dispatch>; + handlePublish: () => void; + handleUnpublish: () => void; + showPublishModal: boolean; + surveyUrl: string; +} +export default function ShareSurveyResults({ + open, + setOpen, + handlePublish, + handleUnpublish, + showPublishModal, + surveyUrl, +}: ShareEmbedSurveyProps) { + return ( + { + setOpen(open); + }}> + {showPublishModal && surveyUrl ? ( + +
+ +
+ Your survey results are public on the web. +
+
+ Your survey results are shared with anyone who has the link. +
+
+ The results will not be indexed by search engines. +
+ +
+
+ + {surveyUrl} + +
+ +
+ +
+ + + +
+
+
+ ) : ( + +
+ +
+ Publish Results to web +
+
+ Your survey results are shared with anyone who has the link. +
+
+ The results will not be indexed by search engines. +
+ +
+
+ )} +
+ ); +} diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SuccessMessage.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SuccessMessage.tsx index b85abf0c59..ea5e935270 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SuccessMessage.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SuccessMessage.tsx @@ -1,30 +1,32 @@ "use client"; -import { TSurvey } from "@formbricks/types/v1/surveys"; -import { Confetti } from "@formbricks/ui/Confetti"; import { useSearchParams } from "next/navigation"; import { useEffect, useState } from "react"; import toast from "react-hot-toast"; + +import { TEnvironment } from "@formbricks/types/environment"; +import { TProduct } from "@formbricks/types/product"; +import { TSurvey } from "@formbricks/types/surveys"; +import { TUser } from "@formbricks/types/user"; +import { Confetti } from "@formbricks/ui/Confetti"; + import ShareEmbedSurvey from "./ShareEmbedSurvey"; -import { TProduct } from "@formbricks/types/v1/product"; -import { TEnvironment } from "@formbricks/types/v1/environment"; -import { TProfile } from "@formbricks/types/v1/profile"; interface SummaryMetadataProps { environment: TEnvironment; survey: TSurvey; - surveyBaseUrl: string; + webAppUrl: string; product: TProduct; - profile: TProfile; + user: TUser; singleUseIds?: string[]; } export default function SuccessMessage({ environment, survey, - surveyBaseUrl, + webAppUrl, product, - profile, + user, }: SummaryMetadataProps) { const searchParams = useSearchParams(); const [showLinkModal, setShowLinkModal] = useState(false); @@ -60,9 +62,9 @@ export default function SuccessMessage({ survey={survey} open={showLinkModal} setOpen={setShowLinkModal} - surveyBaseUrl={surveyBaseUrl} + webAppUrl={webAppUrl} product={product} - profile={profile} + user={user} /> {confetti && } diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryDropOffs.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryDropOffs.tsx new file mode 100644 index 0000000000..75a41b69f0 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryDropOffs.tsx @@ -0,0 +1,201 @@ +import { evaluateCondition } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/evaluateLogic"; +import { TimerIcon } from "lucide-react"; +import { useCallback, useEffect, useMemo, useState } from "react"; + +import { TResponse } from "@formbricks/types/responses"; +import { TSurvey } from "@formbricks/types/surveys"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@formbricks/ui/Tooltip"; + +interface SummaryDropOffsProps { + survey: TSurvey; + responses: TResponse[]; + displayCount: number; +} + +export default function SummaryDropOffs({ responses, survey, displayCount }: SummaryDropOffsProps) { + const initialAvgTtc = useMemo( + () => + survey.questions.reduce((acc, question) => { + acc[question.id] = 0; + return acc; + }, {}), + [survey.questions] + ); + + const [avgTtc, setAvgTtc] = useState(initialAvgTtc); + + interface DropoffMetricsType { + dropoffCount: number[]; + viewsCount: number[]; + dropoffPercentage: number[]; + } + const [dropoffMetrics, setDropoffMetrics] = useState({ + dropoffCount: [], + viewsCount: [], + dropoffPercentage: [], + }); + + const calculateMetrics = useCallback(() => { + let totalTtc = { ...initialAvgTtc }; + let responseCounts = { ...initialAvgTtc }; + + let dropoffArr = new Array(survey.questions.length).fill(0); + let viewsArr = new Array(survey.questions.length).fill(0); + let dropoffPercentageArr = new Array(survey.questions.length).fill(0); + + responses.forEach((response) => { + // Calculate total time-to-completion + Object.keys(avgTtc).forEach((questionId) => { + if (response.ttc && response.ttc[questionId]) { + totalTtc[questionId] += response.ttc[questionId]; + responseCounts[questionId]++; + } + }); + + let currQuesIdx = 0; + + while (currQuesIdx < survey.questions.length) { + const currQues = survey.questions[currQuesIdx]; + if (!currQues) break; + + if (!currQues.required) { + if (!response.data[currQues.id]) { + viewsArr[currQuesIdx]++; + + if (currQuesIdx === survey.questions.length - 1 && !response.finished) { + dropoffArr[currQuesIdx]++; + break; + } + + const questionHasCustomLogic = currQues.logic; + if (questionHasCustomLogic) { + let didLogicPass = false; + for (let logic of questionHasCustomLogic) { + if (!logic.destination) continue; + if (evaluateCondition(logic, response.data[currQues.id] ?? null)) { + didLogicPass = true; + currQuesIdx = survey.questions.findIndex((q) => q.id === logic.destination); + break; + } + } + if (!didLogicPass) currQuesIdx++; + } else { + currQuesIdx++; + } + continue; + } + } + + if ( + (response.data[currQues.id] === undefined && !response.finished) || + (currQues.required && !response.data[currQues.id]) + ) { + dropoffArr[currQuesIdx]++; + viewsArr[currQuesIdx]++; + break; + } + + viewsArr[currQuesIdx]++; + + let nextQuesIdx = currQuesIdx + 1; + const questionHasCustomLogic = currQues.logic; + + if (questionHasCustomLogic) { + for (let logic of questionHasCustomLogic) { + if (!logic.destination) continue; + if (evaluateCondition(logic, response.data[currQues.id])) { + nextQuesIdx = survey.questions.findIndex((q) => q.id === logic.destination); + break; + } + } + } + + if (!response.data[survey.questions[nextQuesIdx]?.id] && !response.finished) { + dropoffArr[nextQuesIdx]++; + viewsArr[nextQuesIdx]++; + break; + } + + currQuesIdx = nextQuesIdx; + } + }); + + // Calculate the average time for each question + Object.keys(totalTtc).forEach((questionId) => { + totalTtc[questionId] = + responseCounts[questionId] > 0 ? totalTtc[questionId] / responseCounts[questionId] : 0; + }); + + if (!survey.welcomeCard.enabled) { + dropoffArr[0] = displayCount - viewsArr[0]; + if (viewsArr[0] > displayCount) dropoffPercentageArr[0] = 0; + + dropoffPercentageArr[0] = + viewsArr[0] - displayCount >= 0 ? 0 : ((displayCount - viewsArr[0]) / displayCount) * 100 || 0; + + viewsArr[0] = displayCount; + } else { + dropoffPercentageArr[0] = (dropoffArr[0] / viewsArr[0]) * 100; + } + + for (let i = 1; i < survey.questions.length; i++) { + if (viewsArr[i] !== 0) { + dropoffPercentageArr[i] = (dropoffArr[i] / viewsArr[i]) * 100; + } + } + + return { + newAvgTtc: totalTtc, + dropoffCount: dropoffArr, + viewsCount: viewsArr, + dropoffPercentage: dropoffPercentageArr, + }; + }, [responses, survey.questions, displayCount, initialAvgTtc, avgTtc]); + + useEffect(() => { + const { newAvgTtc, dropoffCount, viewsCount, dropoffPercentage } = calculateMetrics(); + setAvgTtc(newAvgTtc); + setDropoffMetrics({ dropoffCount, viewsCount, dropoffPercentage }); + }, [responses]); + + return ( +
+
+
+
Questions
+
+ + + + + + +

Average time to complete each question.

+
+
+
+
+
Views
+
Drop Offs
+
+ {survey.questions.map((question, i) => ( +
+
{question.headline}
+
+ {avgTtc[question.id] !== undefined ? (avgTtc[question.id] / 1000).toFixed(2) + "s" : "N/A"} +
+
+ {dropoffMetrics.viewsCount[i]} +
+
+ {dropoffMetrics.dropoffCount[i]} + ({Math.round(dropoffMetrics.dropoffPercentage[i])}%) +
+
+ ))} +
+
+ ); +} diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryList.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryList.tsx index 4ed096db04..a0bce72259 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryList.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryList.tsx @@ -1,11 +1,18 @@ import EmptyInAppSurveys from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/EmptyInAppSurveys"; +import CalSummary from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/CalSummary"; import ConsentSummary from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ConsentSummary"; import HiddenFieldsSummary from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/HiddenFieldsSummary"; -import EmptySpaceFiller from "@formbricks/ui/EmptySpaceFiller"; -import { QuestionType } from "@formbricks/types/questions"; -import type { QuestionSummary } from "@formbricks/types/responses"; -import { TEnvironment } from "@formbricks/types/v1/environment"; -import { TResponse } from "@formbricks/types/v1/responses"; + +import { TEnvironment } from "@formbricks/types/environment"; +import { TResponse } from "@formbricks/types/responses"; +import { TSurveyQuestionType } from "@formbricks/types/surveys"; +import type { + TSurveyCalQuestion, + TSurveyDateQuestion, + TSurveyFileUploadQuestion, + TSurveyPictureSelectionQuestion, + TSurveyQuestionSummary, +} from "@formbricks/types/surveys"; import { TSurvey, TSurveyCTAQuestion, @@ -16,21 +23,27 @@ import { TSurveyOpenTextQuestion, TSurveyQuestion, TSurveyRatingQuestion, -} from "@formbricks/types/v1/surveys"; +} from "@formbricks/types/surveys"; +import EmptySpaceFiller from "@formbricks/ui/EmptySpaceFiller"; + import CTASummary from "./CTASummary"; +import DateQuestionSummary from "./DateQuestionSummary"; +import FileUploadSummary from "./FileUploadSummary"; import MultipleChoiceSummary from "./MultipleChoiceSummary"; import NPSSummary from "./NPSSummary"; import OpenTextSummary from "./OpenTextSummary"; +import PictureChoiceSummary from "./PictureChoiceSummary"; import RatingSummary from "./RatingSummary"; interface SummaryListProps { environment: TEnvironment; survey: TSurvey; responses: TResponse[]; + responsesPerPage: number; } -export default function SummaryList({ environment, survey, responses }: SummaryListProps) { - const getSummaryData = (): QuestionSummary[] => +export default function SummaryList({ environment, survey, responses, responsesPerPage }: SummaryListProps) { + const getSummaryData = (): TSurveyQuestionSummary[] => survey.questions.map((question) => { const questionResponses = responses .filter((response) => question.id in response.data) @@ -40,6 +53,7 @@ export default function SummaryList({ environment, survey, responses }: SummaryL updatedAt: r.updatedAt, person: r.person, })); + return { question, responses: questionResponses, @@ -47,94 +61,133 @@ export default function SummaryList({ environment, survey, responses }: SummaryL }); return ( - <> -
- {survey.type === "web" && responses.length === 0 && !environment.widgetSetupCompleted ? ( - - ) : responses.length === 0 ? ( - - ) : ( - <> - {getSummaryData().map((questionSummary) => { - if (questionSummary.question.type === QuestionType.OpenText) { - return ( - } - environmentId={environment.id} - /> - ); - } - if ( - questionSummary.question.type === QuestionType.MultipleChoiceSingle || - questionSummary.question.type === QuestionType.MultipleChoiceMulti - ) { - return ( - - } - environmentId={environment.id} - surveyType={survey.type} - /> - ); - } - if (questionSummary.question.type === QuestionType.NPS) { - return ( - } - /> - ); - } - if (questionSummary.question.type === QuestionType.CTA) { - return ( - } - /> - ); - } - if (questionSummary.question.type === QuestionType.Rating) { - return ( - } - /> - ); - } - if (questionSummary.question.type === QuestionType.Consent) { - return ( - } - /> - ); - } - return null; +
+ {survey.type === "web" && responses.length === 0 && !environment.widgetSetupCompleted ? ( + + ) : responses.length === 0 ? ( + + ) : ( + <> + {getSummaryData().map((questionSummary) => { + if (questionSummary.question.type === TSurveyQuestionType.OpenText) { + return ( + } + environmentId={environment.id} + responsesPerPage={responsesPerPage} + /> + ); + } + if ( + questionSummary.question.type === TSurveyQuestionType.MultipleChoiceSingle || + questionSummary.question.type === TSurveyQuestionType.MultipleChoiceMulti + ) { + return ( + + } + environmentId={environment.id} + surveyType={survey.type} + responsesPerPage={responsesPerPage} + /> + ); + } + if (questionSummary.question.type === TSurveyQuestionType.NPS) { + return ( + } + /> + ); + } + if (questionSummary.question.type === TSurveyQuestionType.CTA) { + return ( + } + /> + ); + } + if (questionSummary.question.type === TSurveyQuestionType.Rating) { + return ( + } + /> + ); + } + if (questionSummary.question.type === TSurveyQuestionType.Consent) { + return ( + } + /> + ); + } + if (questionSummary.question.type === TSurveyQuestionType.PictureSelection) { + return ( + } + /> + ); + } + if (questionSummary.question.type === TSurveyQuestionType.Date) { + return ( + } + environmentId={environment.id} + responsesPerPage={responsesPerPage} + /> + ); + } + if (questionSummary.question.type === TSurveyQuestionType.FileUpload) { + return ( + } + environmentId={environment.id} + /> + ); + } + + if (questionSummary.question.type === TSurveyQuestionType.Cal) { + return ( + } + environmentId={environment.id} + /> + ); + } + + return null; + })} + + {survey.hiddenFields?.enabled && + survey.hiddenFields.fieldIds?.map((question) => { + return ( + + ); })} - {survey.hiddenFields?.enabled && - survey.hiddenFields.fieldIds?.map((question) => { - return ( - - ); - })} - - )} -
- + + )} +
); } diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryMetadata.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryMetadata.tsx index d2a1d5a886..8e1f591eb8 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryMetadata.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryMetadata.tsx @@ -1,10 +1,16 @@ +import { ChevronDownIcon, ChevronUpIcon } from "@heroicons/react/24/solid"; +import { useMemo, useState } from "react"; + import { timeSinceConditionally } from "@formbricks/lib/time"; -import { TResponse } from "@formbricks/types/v1/responses"; -import { TSurvey } from "@formbricks/types/v1/surveys"; +import { TResponse } from "@formbricks/types/responses"; +import { TSurvey } from "@formbricks/types/surveys"; +import { Button } from "@formbricks/ui/Button"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@formbricks/ui/Tooltip"; interface SummaryMetadataProps { responses: TResponse[]; + showDropOffs: boolean; + setShowDropOffs: React.Dispatch>; survey: TSurvey; displayCount: number; } @@ -30,43 +36,94 @@ const StatCard = ({ label, percentage, value, tooltipText }) => ( ); -export default function SummaryMetadata({ responses, survey, displayCount }: SummaryMetadataProps) { - const completedResponses = responses.filter((r) => r.finished).length; +function formatTime(ttc, totalResponses) { + const seconds = ttc / (1000 * totalResponses); + let formattedValue; + + if (seconds >= 60) { + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + formattedValue = `${minutes}m ${remainingSeconds.toFixed(2)}s`; + } else { + formattedValue = `${seconds.toFixed(2)}s`; + } + + return formattedValue; +} + +export default function SummaryMetadata({ + responses, + survey, + displayCount, + setShowDropOffs, + showDropOffs, +}: SummaryMetadataProps) { + const completedResponsesCount = useMemo(() => responses.filter((r) => r.finished).length, [responses]); + const [validTtcResponsesCount, setValidResponsesCount] = useState(0); + + const ttc = useMemo(() => { + let validTtcResponsesCountAcc = 0; //stores the count of responses that contains a _total value + const ttc = responses.reduce((acc, response) => { + if (response.ttc?._total) { + validTtcResponsesCountAcc++; + return acc + response.ttc._total; + } + return acc; + }, 0); + setValidResponsesCount(validTtcResponsesCountAcc); + return ttc; + }, [responses]); + const totalResponses = responses.length; return (
-
-
-
-

Displays

-

- {displayCount === 0 ? - : displayCount} -

-
+
+
+ - : displayCount} + tooltipText="Number of times the survey has been viewed." + /> - : totalResponses} - tooltipText="People who started the survey." + tooltipText="Number of times the survey has been started." /> - : completedResponses} - tooltipText="People who completed the survey." + percentage={`${Math.round((completedResponsesCount / displayCount) * 100)}%`} + value={responses.length === 0 ? - : completedResponsesCount} + tooltipText="Number of times the survey has been completed." /> - : totalResponses - completedResponses} - tooltipText="People who started but not completed the survey." + percentage={`${Math.round(((totalResponses - completedResponsesCount) / totalResponses) * 100)}%`} + value={responses.length === 0 ? - : totalResponses - completedResponsesCount} + tooltipText="Number of times the survey has been started but not completed." + /> + - : `${formatTime(ttc, validTtcResponsesCount)}` + } + tooltipText="Average time to complete the survey." />
-
+
Last updated: {timeSinceConditionally(survey.updatedAt.toISOString())}
+
diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryPage.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryPage.tsx index 66697d8c17..408855c967 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryPage.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryPage.tsx @@ -2,31 +2,36 @@ import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext"; import SurveyResultsTabs from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyResultsTabs"; +import SummaryDropOffs from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryDropOffs"; import SummaryList from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryList"; import SummaryMetadata from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryMetadata"; import CustomFilter from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter"; import SummaryHeader from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/SummaryHeader"; import { getFilterResponses } from "@/app/lib/surveys/surveys"; -import { TEnvironment } from "@formbricks/types/v1/environment"; -import { TProduct } from "@formbricks/types/v1/product"; -import { TProfile } from "@formbricks/types/v1/profile"; -import { TResponse } from "@formbricks/types/v1/responses"; -import { TSurvey } from "@formbricks/types/v1/surveys"; -import { TTag } from "@formbricks/types/v1/tags"; -import ContentWrapper from "@formbricks/ui/ContentWrapper"; import { useSearchParams } from "next/navigation"; -import { useEffect, useMemo } from "react"; +import { useEffect, useMemo, useState } from "react"; + +import { TEnvironment } from "@formbricks/types/environment"; +import { TMembershipRole } from "@formbricks/types/memberships"; +import { TProduct } from "@formbricks/types/product"; +import { TResponse } from "@formbricks/types/responses"; +import { TSurvey } from "@formbricks/types/surveys"; +import { TTag } from "@formbricks/types/tags"; +import { TUser } from "@formbricks/types/user"; +import ContentWrapper from "@formbricks/ui/ContentWrapper"; interface SummaryPageProps { environment: TEnvironment; survey: TSurvey; surveyId: string; responses: TResponse[]; - surveyBaseUrl: string; + webAppUrl: string; product: TProduct; - profile: TProfile; + user: TUser; environmentTags: TTag[]; displayCount: number; + responsesPerPage: number; + membershipRole?: TMembershipRole; } const SummaryPage = ({ @@ -34,13 +39,16 @@ const SummaryPage = ({ survey, surveyId, responses, - surveyBaseUrl, + webAppUrl, product, - profile, + user, environmentTags, displayCount, + responsesPerPage, + membershipRole, }: SummaryPageProps) => { const { selectedFilter, dateRange, resetState } = useResponseFilter(); + const [showDropOffs, setShowDropOffs] = useState(false); const searchParams = useSearchParams(); useEffect(() => { @@ -60,9 +68,10 @@ const SummaryPage = ({ environment={environment} survey={survey} surveyId={surveyId} - surveyBaseUrl={surveyBaseUrl} + webAppUrl={webAppUrl} product={product} - profile={profile} + user={user} + membershipRole={membershipRole} /> - - + + {showDropOffs && } + ); }; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/evaluateLogic.ts b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/evaluateLogic.ts new file mode 100644 index 0000000000..f6cd083924 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/evaluateLogic.ts @@ -0,0 +1,55 @@ +import { TSurveyLogic } from "@formbricks/types/surveys"; + +export function evaluateCondition(logic: TSurveyLogic, responseValue: any): boolean { + switch (logic.condition) { + case "equals": + return ( + (Array.isArray(responseValue) && responseValue.length === 1 && responseValue.includes(logic.value)) || + responseValue?.toString() === logic.value + ); + case "notEquals": + return responseValue !== logic.value; + case "lessThan": + return logic.value !== undefined && responseValue < logic.value; + case "lessEqual": + return logic.value !== undefined && responseValue <= logic.value; + case "greaterThan": + return logic.value !== undefined && responseValue > logic.value; + case "greaterEqual": + return logic.value !== undefined && responseValue >= logic.value; + case "includesAll": + return ( + Array.isArray(responseValue) && + Array.isArray(logic.value) && + logic.value.every((v) => responseValue.includes(v)) + ); + case "includesOne": + return ( + Array.isArray(responseValue) && + Array.isArray(logic.value) && + logic.value.some((v) => responseValue.includes(v)) + ); + case "accepted": + return responseValue === "accepted"; + case "clicked": + return responseValue === "clicked"; + case "submitted": + if (typeof responseValue === "string") { + return responseValue !== "dismissed" && responseValue !== "" && responseValue !== null; + } else if (Array.isArray(responseValue)) { + return responseValue.length > 0; + } else if (typeof responseValue === "number") { + return responseValue !== null; + } + return false; + case "skipped": + return ( + (Array.isArray(responseValue) && responseValue.length === 0) || + responseValue === "" || + responseValue === null || + responseValue === "dismissed" + ); + default: + return false; + } +} diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedTabs/EmailTab.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedTabs/EmailTab.tsx index e318604bdd..cf3350a9ad 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedTabs/EmailTab.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedTabs/EmailTab.tsx @@ -1,68 +1,64 @@ "use client"; -import { cn } from "@formbricks/lib/cn"; -import { Button } from "@formbricks/ui/Button"; -import { Input } from "@formbricks/ui/Input"; -import { QuestionType } from "@formbricks/types/questions"; -import { TSurvey } from "@formbricks/types/v1/surveys"; -import { AuthenticationError } from "@formbricks/types/v1/errors"; -import { sendEmailAction } from "../../actions"; -import CodeBlock from "@formbricks/ui/CodeBlock"; import { CodeBracketIcon, DocumentDuplicateIcon, EnvelopeIcon } from "@heroicons/react/24/solid"; -import { - Column, - Container, - Button as EmailButton, - Link, - Row, - Section, - Tailwind, - Text, - render, -} from "@react-email/components"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import toast from "react-hot-toast"; -import { isLight } from "@/app/lib/utils"; -import { CornerDownLeft } from "lucide-react"; -import Image from "next/image"; + +import { AuthenticationError } from "@formbricks/types/errors"; +import { Button } from "@formbricks/ui/Button"; +import CodeBlock from "@formbricks/ui/CodeBlock"; +import LoadingSpinner from "@formbricks/ui/LoadingSpinner"; + +import { getEmailHtmlAction, sendEmailAction } from "../../actions"; interface EmailTabProps { - survey: TSurvey; - surveyUrl: string; + surveyId: string; email: string; - brandColor: string; } -export default function EmailTab({ survey, surveyUrl, email, brandColor }: EmailTabProps) { +export default function EmailTab({ surveyId, email }: EmailTabProps) { const [showEmbed, setShowEmbed] = useState(false); + const [emailHtmlPreview, setEmailHtmlPreview] = useState(""); + + const emailHtml = useMemo(() => { + if (!emailHtmlPreview) return ""; + return emailHtmlPreview + .replaceAll("?preview=true&", "?") + .replaceAll("?preview=true&;", "?") + .replaceAll("?preview=true", ""); + }, [emailHtmlPreview]); + + useEffect(() => { + getData(); + + async function getData() { + const emailHtml = await getEmailHtmlAction(surveyId); + setEmailHtmlPreview(emailHtml); + } + }); + const subject = "Formbricks Email Survey Preview"; - const emailValues = useMemo(() => { - return getEmailValues({ brandColor, survey, surveyUrl, preview: false }); - }, []); - - const previewEmailValues = useMemo(() => { - return getEmailValues({ brandColor, survey, surveyUrl, preview: true }); - }, []); - - const sendPreviewEmail = async () => { + const sendPreviewEmail = async (html) => { try { - await sendEmailAction({ html: previewEmailValues.html, subject, to: email }); + await sendEmailAction({ + html, + subject, + to: email, + }); toast.success("Email sent!"); } catch (err) { if (err instanceof AuthenticationError) { toast.error("You are not authenticated to perform this action."); return; } - toast.error("Something went wrong. Please try again later."); } }; return (
-
- +
{showEmbed ? ( ) : ( - + <> + + )}
); } - -const getEmailValues = ({ survey, surveyUrl, brandColor, preview }) => { - const doctype = - ''; - - const Template = getEmailTemplate(survey, surveyUrl, brandColor, preview); - const html = render(Template, { pretty: true }); - const htmlWithoutDoctype = html.replace(doctype, ""); - - return { Component: Template, html: htmlWithoutDoctype }; -}; - -const getEmailTemplate = (survey: TSurvey, surveyUrl: string, brandColor: string, preview: boolean) => { - const url = preview ? `${surveyUrl}?preview=true` : surveyUrl; - const urlWithPrefilling = preview ? `${surveyUrl}?preview=true&` : `${surveyUrl}?`; - if (survey?.welcomeCard?.enabled) { - return ( - - {survey?.welcomeCard?.fileUrl && ( - Company Logo - )} - - - {survey?.welcomeCard?.headline} - - - - - - - - {survey?.welcomeCard?.buttonLabel || "Next"} - - - Press Enter - - - - - - ); - } else { - const firstQuestion = survey.questions[0]; - switch (firstQuestion.type) { - case QuestionType.OpenText: - return ( - - - {firstQuestion.headline} - - - {firstQuestion.subheader} - -
- - - ); - case QuestionType.Consent: - return ( - - - {firstQuestion.headline} - - - - - - - {firstQuestion.label} - - - {!firstQuestion.required && ( - - Reject - - )} - - Accept - - - - - ); - case QuestionType.NPS: - return ( - -
- - {firstQuestion.headline} - - - {firstQuestion.subheader} - - -
- {Array.from({ length: 11 }, (_, i) => ( - - {i} - - ))} -
-
- - - {firstQuestion.lowerLabel} - - - - {firstQuestion.upperLabel} - - - -
-
- {/* {!firstQuestion.required && ( - - {firstQuestion.buttonLabel || "Skip"} - - )} */} - - -
-
- ); - case QuestionType.CTA: - return ( - - - {firstQuestion.headline} - - - - - - - {!firstQuestion.required && ( - - {firstQuestion.dismissButtonLabel || "Skip"} - - )} - - {firstQuestion.buttonLabel} - - - - - ); - case QuestionType.Rating: - return ( - -
- - {firstQuestion.headline} - - - {firstQuestion.subheader} - - -
- {Array.from({ length: firstQuestion.range }, (_, i) => ( - - {firstQuestion.scale === "smiley" && 😃} - {firstQuestion.scale === "number" && i + 1} - {firstQuestion.scale === "star" && } - - ))} -
-
- - - {firstQuestion.lowerLabel} - - - {firstQuestion.upperLabel} - - -
-
- {/* {!firstQuestion.required && ( - - {firstQuestion.buttonLabel || "Skip"} - - )} */} - -
-
- ); - case QuestionType.MultipleChoiceMulti: - return ( - - - {firstQuestion.headline} - - - {firstQuestion.subheader} - - - {firstQuestion.choices.map((choice) => ( -
- {choice.label} -
- ))} -
- -
- ); - case QuestionType.MultipleChoiceSingle: - return ( - - - {firstQuestion.headline} - - - {firstQuestion.subheader} - - - {firstQuestion.choices - .filter((choice) => choice.id !== "other") - .map((choice) => ( - - {choice.label} - - ))} - - - - ); - } - } -}; - -const EmailTemplateWrapper = ({ children, surveyUrl, brandColor }) => { - return ( - - - {children} - - - ); -}; - -const EmailFooter = () => { - return ( - - - Powered by Formbricks - - - ); -}; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedTabs/LinkTab.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedTabs/LinkTab.tsx index 5a21dae30b..2b53f5357f 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedTabs/LinkTab.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedTabs/LinkTab.tsx @@ -1,13 +1,14 @@ "use client"; -import toast from "react-hot-toast"; -import { SurveyInline } from "@formbricks/ui/Survey"; -import { cn } from "@formbricks/lib/cn"; -import { TSurvey } from "@formbricks/types/v1/surveys"; -import { Button } from "@formbricks/ui/Button"; import { DocumentDuplicateIcon } from "@heroicons/react/24/solid"; import { ArrowUpRightIcon } from "lucide-react"; import { useRef } from "react"; +import toast from "react-hot-toast"; + +import { cn } from "@formbricks/lib/cn"; +import { TSurvey } from "@formbricks/types/surveys"; +import { Button } from "@formbricks/ui/Button"; +import { SurveyInline } from "@formbricks/ui/Survey"; interface EmailTabProps { surveyUrl: string; @@ -56,10 +57,11 @@ export default function LinkTab({ surveyUrl, survey, brandColor }: EmailTabProps ""} /> + {!isViewer && ( + + )}
@@ -84,12 +91,12 @@ const SummaryHeader = ({ {survey.type === "link" && ( <> - @@ -123,10 +130,10 @@ const SummaryHeader = ({ value === "inProgress" ? "Survey live" : value === "paused" - ? "Survey paused" - : value === "completed" - ? "Survey completed" - : "" + ? "Survey paused" + : value === "completed" + ? "Survey completed" + : "" ); router.refresh(); }) @@ -172,9 +179,9 @@ const SummaryHeader = ({
); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/SurveyStatusDropdown.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/SurveyStatusDropdown.tsx index 32d68d0e9b..4a24849f2f 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/SurveyStatusDropdown.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/SurveyStatusDropdown.tsx @@ -1,14 +1,15 @@ "use client"; import { updateSurveyAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/actions"; -import { SurveyStatusIndicator } from "@formbricks/ui/SurveyStatusIndicator"; -import { TEnvironment } from "@formbricks/types/v1/environment"; -import { TSurvey } from "@formbricks/types/v1/surveys"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@formbricks/ui/Select"; -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@formbricks/ui/Tooltip"; import { CheckCircleIcon, PauseCircleIcon, PlayCircleIcon } from "@heroicons/react/24/solid"; import toast from "react-hot-toast"; +import { TEnvironment } from "@formbricks/types/environment"; +import { TSurvey } from "@formbricks/types/surveys"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@formbricks/ui/Select"; +import { SurveyStatusIndicator } from "@formbricks/ui/SurveyStatusIndicator"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@formbricks/ui/Tooltip"; + export default function SurveyStatusDropdown({ environment, updateLocalSurveyStatus, @@ -43,10 +44,10 @@ export default function SurveyStatusDropdown({ value === "inProgress" ? "Survey live" : value === "paused" - ? "Survey paused" - : value === "completed" - ? "Survey completed" - : "" + ? "Survey paused" + : value === "completed" + ? "Survey completed" + : "" ); }) .catch((error) => { diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/actions.ts b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/actions.ts index 76b76f2945..3db32c6565 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/actions.ts +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/actions.ts @@ -1,13 +1,14 @@ "use server"; -import { authOptions } from "@formbricks/lib/authOptions"; -import { canUserAccessSurvey } from "@formbricks/lib/survey/auth"; -import { deleteSurvey, updateSurvey } from "@formbricks/lib/survey/service"; -import { formatSurveyDateFields } from "@formbricks/lib/survey/util"; -import { AuthorizationError } from "@formbricks/types/v1/errors"; -import { TSurvey } from "@formbricks/types/v1/surveys"; import { getServerSession } from "next-auth"; +import { authOptions } from "@formbricks/lib/authOptions"; +import { canUserAccessSurvey, verifyUserRoleAccess } from "@formbricks/lib/survey/auth"; +import { deleteSurvey, getSurvey, updateSurvey } from "@formbricks/lib/survey/service"; +import { formatSurveyDateFields } from "@formbricks/lib/survey/util"; +import { AuthorizationError } from "@formbricks/types/errors"; +import { TSurvey } from "@formbricks/types/surveys"; + export async function updateSurveyAction(survey: TSurvey): Promise { const session = await getServerSession(authOptions); if (!session) throw new AuthorizationError("Not authorized"); @@ -15,6 +16,9 @@ export async function updateSurveyAction(survey: TSurvey): Promise { const isAuthorized = await canUserAccessSurvey(session.user.id, survey.id); if (!isAuthorized) throw new AuthorizationError("Not authorized"); + const { hasCreateOrUpdateAccess } = await verifyUserRoleAccess(survey.environmentId, session.user.id); + if (!hasCreateOrUpdateAccess) throw new AuthorizationError("Not authorized"); + const _survey = { ...survey, ...formatSurveyDateFields(survey), @@ -30,5 +34,9 @@ export const deleteSurveyAction = async (surveyId: string) => { const isAuthorized = await canUserAccessSurvey(session.user.id, surveyId); if (!isAuthorized) throw new AuthorizationError("Not authorized"); + const survey = await getSurvey(surveyId); + const { hasDeleteAccess } = await verifyUserRoleAccess(survey!.environmentId, session.user.id); + if (!hasDeleteAccess) throw new AuthorizationError("Not authorized"); + await deleteSurvey(surveyId); }; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/AddQuestionButton.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/AddQuestionButton.tsx index 50be92acff..74b64ed41e 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/AddQuestionButton.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/AddQuestionButton.tsx @@ -1,13 +1,14 @@ "use client"; import { getQuestionDefaults, questionTypes, universalQuestionPresets } from "@/app/lib/questions"; -import { cn } from "@formbricks/lib/cn"; -import { TProduct } from "@formbricks/types/v1/product"; import { PlusIcon } from "@heroicons/react/24/solid"; import { createId } from "@paralleldrive/cuid2"; import * as Collapsible from "@radix-ui/react-collapsible"; import { useState } from "react"; +import { cn } from "@formbricks/lib/cn"; +import { TProduct } from "@formbricks/types/product"; + interface AddQuestionButtonProps { addQuestion: (question: any) => void; product: TProduct; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/AdvancedSettings.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/AdvancedSettings.tsx index 8dfd233fc0..abf595e74d 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/AdvancedSettings.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/AdvancedSettings.tsx @@ -1,5 +1,7 @@ import LogicEditor from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/LogicEditor"; -import { TSurvey, TSurveyQuestion } from "@formbricks/types/v1/surveys"; + +import { TSurvey, TSurveyQuestion } from "@formbricks/types/surveys"; + import UpdateQuestionId from "./UpdateQuestionId"; interface AdvancedSettingsProps { diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/AnimatedSurveyBg.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/AnimatedSurveyBg.tsx new file mode 100644 index 0000000000..7403b43777 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/AnimatedSurveyBg.tsx @@ -0,0 +1,108 @@ +import { useState } from "react"; + +import { TSurvey } from "@formbricks/types/surveys"; + +interface AnimatedSurveyBgProps { + localSurvey?: TSurvey; + handleBgChange: (bg: string, bgType: string) => void; +} + +export default function AnimatedSurveyBg({ localSurvey, handleBgChange }: AnimatedSurveyBgProps) { + const [color, setColor] = useState(localSurvey?.styling?.background?.bg || "#ffff"); + const [hoveredVideo, setHoveredVideo] = useState(null); + + const animationFiles = { + "/animated-bgs/Thumbnails/1_Thumb.mp4": "/animated-bgs/4K/1_4k.mp4", + "/animated-bgs/Thumbnails/2_Thumb.mp4": "/animated-bgs/4K/2_4k.mp4", + "/animated-bgs/Thumbnails/3_Thumb.mp4": "/animated-bgs/4K/3_4k.mp4", + "/animated-bgs/Thumbnails/4_Thumb.mp4": "/animated-bgs/4K/4_4k.mp4", + "/animated-bgs/Thumbnails/5_Thumb.mp4": "/animated-bgs/4K/5_4k.mp4", + "/animated-bgs/Thumbnails/6_Thumb.mp4": "/animated-bgs/4K/6_4k.mp4", + "/animated-bgs/Thumbnails/7_Thumb.mp4": "/animated-bgs/4K/7_4k.mp4", + "/animated-bgs/Thumbnails/8_Thumb.mp4": "/animated-bgs/4K/8_4k.mp4", + "/animated-bgs/Thumbnails/9_Thumb.mp4": "/animated-bgs/4K/9_4k.mp4", + "/animated-bgs/Thumbnails/10_Thumb.mp4": "/animated-bgs/4K/10_4k.mp4", + "/animated-bgs/Thumbnails/11_Thumb.mp4": "/animated-bgs/4K/11_4k.mp4", + "/animated-bgs/Thumbnails/12_Thumb.mp4": "/animated-bgs/4K/12_4k.mp4", + "/animated-bgs/Thumbnails/13_Thumb.mp4": "/animated-bgs/4K/13_4k.mp4", + "/animated-bgs/Thumbnails/14_Thumb.mp4": "/animated-bgs/4K/14_4k.mp4", + "/animated-bgs/Thumbnails/15_Thumb.mp4": "/animated-bgs/4K/15_4k.mp4", + "/animated-bgs/Thumbnails/16_Thumb.mp4": "/animated-bgs/4K/16_4k.mp4", + "/animated-bgs/Thumbnails/17_Thumb.mp4": "/animated-bgs/4K/17_4k.mp4", + "/animated-bgs/Thumbnails/18_Thumb.mp4": "/animated-bgs/4K/18_4k.mp4", + "/animated-bgs/Thumbnails/19_Thumb.mp4": "/animated-bgs/4K/19_4k.mp4", + "/animated-bgs/Thumbnails/20_Thumb.mp4": "/animated-bgs/4K/20_4k.mp4", + "/animated-bgs/Thumbnails/21_Thumb.mp4": "/animated-bgs/4K/21_4k.mp4", + "/animated-bgs/Thumbnails/22_Thumb.mp4": "/animated-bgs/4K/22_4k.mp4", + "/animated-bgs/Thumbnails/23_Thumb.mp4": "/animated-bgs/4K/23_4k.mp4", + "/animated-bgs/Thumbnails/24_Thumb.mp4": "/animated-bgs/4K/24_4k.mp4", + "/animated-bgs/Thumbnails/25_Thumb.mp4": "/animated-bgs/4K/25_4k.mp4", + "/animated-bgs/Thumbnails/26_Thumb.mp4": "/animated-bgs/4K/26_4k.mp4", + "/animated-bgs/Thumbnails/27_Thumb.mp4": "/animated-bgs/4K/27_4k.mp4", + "/animated-bgs/Thumbnails/28_Thumb.mp4": "/animated-bgs/4K/28_4k.mp4", + "/animated-bgs/Thumbnails/29_Thumb.mp4": "/animated-bgs/4K/29_4k.mp4", + "/animated-bgs/Thumbnails/30_Thumb.mp4": "/animated-bgs/4K/30_4k.mp4", + }; + + const handleMouseEnter = (index: number) => { + setHoveredVideo(index); + playVideo(index); + }; + + const handleMouseLeave = (index: number) => { + setHoveredVideo(null); + pauseVideo(index); + }; + + // Function to play the video + const playVideo = (index: number) => { + const video = document.getElementById(`video-${index}`) as HTMLVideoElement; + if (video) { + video.play(); + } + }; + + // Function to pause the video + const pauseVideo = (index: number) => { + const video = document.getElementById(`video-${index}`) as HTMLVideoElement; + if (video) { + video.pause(); + } + }; + + const handleBg = (x: string) => { + setColor(x); + handleBgChange(x, "animation"); + }; + return ( +
+
+ {Object.keys(animationFiles).map((key, index) => { + const value = animationFiles[key]; + return ( +
handleMouseEnter(index)} + onMouseLeave={() => handleMouseLeave(index)} + onClick={() => handleBg(value)} + className="relative cursor-pointer overflow-hidden rounded-lg"> + + handleBg(value)} + /> +
+ ); + })} +
+
+ ); +} diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/CTAQuestionForm.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/CTAQuestionForm.tsx index 02cd2e036e..c2ed77138a 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/CTAQuestionForm.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/CTAQuestionForm.tsx @@ -1,13 +1,15 @@ "use client"; import { BackButtonInput } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionCard"; +import QuestionFormInput from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionFormInput"; +import { useState } from "react"; + import { md } from "@formbricks/lib/markdownIt"; -import { TSurvey, TSurveyCTAQuestion } from "@formbricks/types/v1/surveys"; +import { TSurvey, TSurveyCTAQuestion } from "@formbricks/types/surveys"; import { Editor } from "@formbricks/ui/Editor"; import { Input } from "@formbricks/ui/Input"; import { Label } from "@formbricks/ui/Label"; import { RadioGroup, RadioGroupItem } from "@formbricks/ui/RadioGroup"; -import { useState } from "react"; interface CTAQuestionFormProps { localSurvey: TSurvey; @@ -24,24 +26,20 @@ export default function CTAQuestionForm({ updateQuestion, lastQuestion, isInValid, + localSurvey, }: CTAQuestionFormProps): JSX.Element { const [firstRender, setFirstRender] = useState(true); + const environmentId = localSurvey.environmentId; return (
-
- -
- updateQuestion(questionIdx, { headline: e.target.value })} - isInvalid={isInValid && question.headline.trim() === ""} - /> -
-
+
diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/CalQuestionForm.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/CalQuestionForm.tsx new file mode 100644 index 0000000000..a06f5f796c --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/CalQuestionForm.tsx @@ -0,0 +1,81 @@ +import { PlusIcon, TrashIcon } from "@heroicons/react/24/solid"; +import { useState } from "react"; + +import { TSurveyCalQuestion } from "@formbricks/types/surveys"; +import { Button } from "@formbricks/ui/Button"; +import { Input } from "@formbricks/ui/Input"; +import { Label } from "@formbricks/ui/Label"; + +interface CalQuestionFormProps { + question: TSurveyCalQuestion; + questionIdx: number; + updateQuestion: (questionIdx: number, updatedAttributes: any) => void; + lastQuestion: boolean; + isInValid: boolean; +} + +export default function CalQuestionForm({ + question, + questionIdx, + updateQuestion, + isInValid, +}: CalQuestionFormProps): JSX.Element { + const [showSubheader, setShowSubheader] = useState(!!question.subheader); + + return ( + +
+ +
+ updateQuestion(questionIdx, { headline: e.target.value })} + isInvalid={isInValid && question.headline.trim() === ""} + /> +
+
+
+ {showSubheader && ( + <> + +
+ updateQuestion(questionIdx, { subheader: e.target.value })} + /> + { + setShowSubheader(false); + updateQuestion(questionIdx, { subheader: "" }); + }} + /> +
+ + )} + {!showSubheader && ( + + )} +
+ +
+ updateQuestion(questionIdx, { calUserName: e.target.value })} + /> +
+
+
+ + ); +} diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/ColorSurveyBg.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/ColorSurveyBg.tsx new file mode 100644 index 0000000000..80f9d10e00 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/ColorSurveyBg.tsx @@ -0,0 +1,39 @@ +import { useState } from "react"; + +import { TSurvey } from "@formbricks/types/surveys"; +import { ColorPicker } from "@formbricks/ui/ColorPicker"; + +interface ColorSurveyBgBgProps { + localSurvey?: TSurvey; + handleBgChange: (bg: string, bgType: string) => void; + colours: string[]; +} + +export default function ColorSurveyBg({ localSurvey, handleBgChange, colours }: ColorSurveyBgBgProps) { + const [color, setColor] = useState(localSurvey?.styling?.background?.bg || "#ffff"); + + const handleBg = (x: string) => { + setColor(x); + handleBgChange(x, "color"); + }; + return ( +
+
+ +
+
+ {colours.map((x) => { + return ( +
handleBg(x)}>
+ ); + })} +
+
+ ); +} diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/ConsentQuestionForm.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/ConsentQuestionForm.tsx index 1a5ea41c4a..fd3711fc7b 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/ConsentQuestionForm.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/ConsentQuestionForm.tsx @@ -1,11 +1,13 @@ "use client"; +import QuestionFormInput from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionFormInput"; +import { useState } from "react"; + import { md } from "@formbricks/lib/markdownIt"; -import { TSurvey, TSurveyConsentQuestion } from "@formbricks/types/v1/surveys"; +import { TSurvey, TSurveyConsentQuestion } from "@formbricks/types/surveys"; import { Editor } from "@formbricks/ui/Editor"; import { Input } from "@formbricks/ui/Input"; import { Label } from "@formbricks/ui/Label"; -import { useState } from "react"; interface ConsentQuestionFormProps { localSurvey: TSurvey; @@ -20,22 +22,20 @@ export default function ConsentQuestionForm({ questionIdx, updateQuestion, isInValid, + localSurvey, }: ConsentQuestionFormProps): JSX.Element { const [firstRender, setFirstRender] = useState(true); + const environmentId = localSurvey.environmentId; + return (
-
- -
- updateQuestion(questionIdx, { headline: e.target.value })} - isInvalid={isInValid && question.headline.trim() === ""} - /> -
-
+
diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/DateQuestionForm.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/DateQuestionForm.tsx new file mode 100644 index 0000000000..8b412e22eb --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/DateQuestionForm.tsx @@ -0,0 +1,96 @@ +import { PlusIcon, TrashIcon } from "@heroicons/react/24/solid"; +import { useState } from "react"; + +import { TSurvey, TSurveyDateQuestion } from "@formbricks/types/surveys"; +import { Button } from "@formbricks/ui/Button"; +import { Input } from "@formbricks/ui/Input"; +import { Label } from "@formbricks/ui/Label"; +import { OptionsSwitcher } from "@formbricks/ui/QuestionTypeSelector"; + +import QuestionFormInput from "./QuestionFormInput"; + +interface IDateQuestionFormProps { + localSurvey: TSurvey; + question: TSurveyDateQuestion; + questionIdx: number; + updateQuestion: (questionIdx: number, updatedAttributes: any) => void; + lastQuestion: boolean; + isInValid: boolean; +} + +const dateOptions = [ + { + value: "M-d-y", + label: "MM-DD-YYYY", + }, + { + value: "d-M-y", + label: "DD-MM-YYYY", + }, + { + value: "y-M-d", + label: "YYYY-MM-DD", + }, +]; + +export default function DateQuestionForm({ + question, + questionIdx, + updateQuestion, + isInValid, + localSurvey, +}: IDateQuestionFormProps): JSX.Element { + const [showSubheader, setShowSubheader] = useState(!!question.subheader); + + return ( + + +
+ {showSubheader && ( + <> + +
+ updateQuestion(questionIdx, { subheader: e.target.value })} + /> + { + setShowSubheader(false); + updateQuestion(questionIdx, { subheader: "" }); + }} + /> +
+ + )} + + {!showSubheader && ( + + )} +
+ +
+ +
+ updateQuestion(questionIdx, { format: value })} + /> +
+
+ + ); +} diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/EditThankYouCard.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/EditThankYouCard.tsx index a7c5cbd696..5b42a58a0d 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/EditThankYouCard.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/EditThankYouCard.tsx @@ -1,11 +1,12 @@ "use client"; +import * as Collapsible from "@radix-ui/react-collapsible"; + import { cn } from "@formbricks/lib/cn"; -import { TSurvey } from "@formbricks/types/v1/surveys"; +import { TSurvey } from "@formbricks/types/surveys"; import { Input } from "@formbricks/ui/Input"; import { Label } from "@formbricks/ui/Label"; import { Switch } from "@formbricks/ui/Switch"; -import * as Collapsible from "@radix-ui/react-collapsible"; interface EditThankYouCardProps { localSurvey: TSurvey; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/EditWelcomeCard.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/EditWelcomeCard.tsx index 940164668a..b9ec84fdff 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/EditWelcomeCard.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/EditWelcomeCard.tsx @@ -1,15 +1,17 @@ "use client"; + +import * as Collapsible from "@radix-ui/react-collapsible"; +import { usePathname } from "next/navigation"; +import { useState } from "react"; + import { cn } from "@formbricks/lib/cn"; import { md } from "@formbricks/lib/markdownIt"; -import { TSurvey } from "@formbricks/types/v1/surveys"; +import { TSurvey } from "@formbricks/types/surveys"; import { Editor } from "@formbricks/ui/Editor"; import FileInput from "@formbricks/ui/FileInput"; import { Input } from "@formbricks/ui/Input"; import { Label } from "@formbricks/ui/Label"; import { Switch } from "@formbricks/ui/Switch"; -import * as Collapsible from "@radix-ui/react-collapsible"; -import { usePathname } from "next/navigation"; -import { useState } from "react"; interface EditWelcomeCardProps { localSurvey: TSurvey; @@ -46,6 +48,7 @@ export default function EditWelcomeCard({ }, }); }; + return (
{ - updateSurvey({ fileUrl: url }); + onFileUpload={(url: string[]) => { + updateSurvey({ fileUrl: url[0] }); }} fileUrl={localSurvey?.welcomeCard?.fileUrl} + imageFit="contain" />
@@ -155,7 +160,7 @@ export default function EditWelcomeCard({
- {/*
+
-
*/} +
+ {localSurvey?.type === "link" && ( +
+
+ + updateSurvey({ showResponseCount: !localSurvey.welcomeCard.showResponseCount }) + } + /> +
+
+ +
+ Display number of responses for survey +
+
+
+ )} diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/FileUploadQuestionForm.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/FileUploadQuestionForm.tsx new file mode 100644 index 0000000000..d1cdbf6c53 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/FileUploadQuestionForm.tsx @@ -0,0 +1,235 @@ +"use client"; + +import { PlusIcon, TrashIcon, XCircleIcon } from "@heroicons/react/24/solid"; +import { useMemo, useState } from "react"; +import { toast } from "react-hot-toast"; + +import { useGetBillingInfo } from "@formbricks/lib/team/hooks/useGetBillingInfo"; +import { TAllowedFileExtension, ZAllowedFileExtension } from "@formbricks/types/common"; +import { TProduct } from "@formbricks/types/product"; +import { TSurvey, TSurveyFileUploadQuestion } from "@formbricks/types/surveys"; +import { AdvancedOptionToggle } from "@formbricks/ui/AdvancedOptionToggle"; +import { Button } from "@formbricks/ui/Button"; +import { Input } from "@formbricks/ui/Input"; +import { Label } from "@formbricks/ui/Label"; + +interface FileUploadFormProps { + localSurvey: TSurvey; + product?: TProduct; + question: TSurveyFileUploadQuestion; + questionIdx: number; + updateQuestion: (questionIdx: number, updatedAttributes: any) => void; + lastQuestion: boolean; + isInValid: boolean; +} + +export default function FileUploadQuestionForm({ + question, + questionIdx, + updateQuestion, + isInValid, + product, +}: FileUploadFormProps): JSX.Element { + const [showSubheader, setShowSubheader] = useState(!!question.subheader); + const [extension, setExtension] = useState(""); + const { + billingInfo, + error: billingInfoError, + isLoading: billingInfoLoading, + } = useGetBillingInfo(product?.teamId ?? ""); + + const handleInputChange = (event) => { + setExtension(event.target.value); + }; + + const addExtension = (event) => { + event.preventDefault(); + event.stopPropagation(); + + let modifiedExtension = extension.trim(); + + // Remove the dot at the start if it exists + if (modifiedExtension.startsWith(".")) { + modifiedExtension = modifiedExtension.substring(1); + } + + if (!modifiedExtension) { + toast.error("Please enter a file extension."); + return; + } + + const parsedExtensionResult = ZAllowedFileExtension.safeParse(modifiedExtension); + + if (!parsedExtensionResult.success) { + toast.error("This file type is not supported."); + return; + } + + if (question.allowedFileExtensions) { + if (!question.allowedFileExtensions.includes(modifiedExtension as TAllowedFileExtension)) { + updateQuestion(questionIdx, { + allowedFileExtensions: [...question.allowedFileExtensions, modifiedExtension], + }); + setExtension(""); + } else { + toast.error("This extension is already added."); + } + } else { + updateQuestion(questionIdx, { allowedFileExtensions: [modifiedExtension] }); + setExtension(""); + } + }; + + const removeExtension = (event, index: number) => { + event.preventDefault(); + if (question.allowedFileExtensions) { + const updatedExtensions = [...question?.allowedFileExtensions]; + updatedExtensions.splice(index, 1); + updateQuestion(questionIdx, { allowedFileExtensions: updatedExtensions }); + } + }; + + const maxSizeInMBLimit = useMemo(() => { + if (billingInfoError || billingInfoLoading || !billingInfo) { + return 10; + } + + if (billingInfo.features.linkSurvey.status === "active") { + // 1GB in MB + return 1024; + } + + return 10; + }, [billingInfo, billingInfoError, billingInfoLoading]); + + return ( +
+
+ +
+ updateQuestion(questionIdx, { headline: e.target.value })} + isInvalid={isInValid && question.headline.trim() === ""} + /> +
+
+
+ {showSubheader && ( + <> + +
+ updateQuestion(questionIdx, { subheader: e.target.value })} + /> + { + setShowSubheader(false); + updateQuestion(questionIdx, { subheader: "" }); + }} + /> +
+ + )} + {!showSubheader && ( + + )} +
+
+ updateQuestion(questionIdx, { allowMultipleFiles: !question.allowMultipleFiles })} + htmlId="allowMultipleFile" + title="Allow Multiple Files" + description="Let people upload up to 10 files at the same time." + childBorder + customContainerClass="p-0"> + + updateQuestion(questionIdx, { maxSizeInMB: checked ? 10 : undefined })} + htmlId="maxFileSize" + title="Max file size" + description="Limit the maximum file size." + childBorder + customContainerClass="p-0"> + + + + + updateQuestion(questionIdx, { allowedFileExtensions: checked ? [] : undefined }) + } + htmlId="limitFileType" + title="Limit file types" + description="Control which file types can be uploaded." + childBorder + customContainerClass="p-0"> +
+
+ {question.allowedFileExtensions && + question.allowedFileExtensions.map((item, index) => ( +
+

{item}

+ +
+ ))} +
+
+ + +
+
+
+
+
+ ); +} diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/HiddenFieldsCard.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/HiddenFieldsCard.tsx index feed0d75dd..b47433e727 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/HiddenFieldsCard.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/HiddenFieldsCard.tsx @@ -1,14 +1,15 @@ "use client"; +import * as Collapsible from "@radix-ui/react-collapsible"; +import { FC, useState } from "react"; +import toast from "react-hot-toast"; + import { cn } from "@formbricks/lib/cn"; -import { TSurvey, TSurveyHiddenFields, TSurveyQuestions } from "@formbricks/types/v1/surveys"; +import { TSurvey, TSurveyHiddenFields, TSurveyQuestions } from "@formbricks/types/surveys"; import { Input } from "@formbricks/ui/Input"; import { Label } from "@formbricks/ui/Label"; import { Switch } from "@formbricks/ui/Switch"; import { Tag } from "@formbricks/ui/Tag"; -import * as Collapsible from "@radix-ui/react-collapsible"; -import { FC, useState } from "react"; -import toast from "react-hot-toast"; interface HiddenFieldsCardProps { localSurvey: TSurvey; @@ -162,7 +163,10 @@ const validateHiddenField = ( return "Question already exists"; } // no key words -- userId & suid & existing question ids - if (["userId", "suid"].includes(field) || existingQuestions.findIndex((q) => q.id === field) !== -1) { + if ( + ["userId", "source", "suid", "end", "start", "welcomeCard", "hidden"].includes(field) || + existingQuestions.findIndex((q) => q.id === field) !== -1 + ) { return "Question not allowed"; } // no spaced words --> should be valid query param on url diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/HowToSendCard.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/HowToSendCard.tsx index c2f276dd10..73614ddc0e 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/HowToSendCard.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/HowToSendCard.tsx @@ -1,15 +1,9 @@ "use client"; -import { cn } from "@formbricks/lib/cn"; -import { TEnvironment } from "@formbricks/types/v1/environment"; -import { TSurvey, TSurveyType } from "@formbricks/types/v1/surveys"; -import { Badge } from "@formbricks/ui/Badge"; -import { Label } from "@formbricks/ui/Label"; -import { RadioGroup, RadioGroupItem } from "@formbricks/ui/RadioGroup"; + import { CheckCircleIcon, ComputerDesktopIcon, DevicePhoneMobileIcon, - EnvelopeIcon, ExclamationCircleIcon, LinkIcon, } from "@heroicons/react/24/solid"; @@ -17,6 +11,13 @@ import * as Collapsible from "@radix-ui/react-collapsible"; import Link from "next/link"; import { useEffect, useState } from "react"; +import { cn } from "@formbricks/lib/cn"; +import { TEnvironment } from "@formbricks/types/environment"; +import { TSurvey, TSurveyType } from "@formbricks/types/surveys"; +import { Badge } from "@formbricks/ui/Badge"; +import { Label } from "@formbricks/ui/Label"; +import { RadioGroup, RadioGroupItem } from "@formbricks/ui/RadioGroup"; + interface HowToSendCardProps { localSurvey: TSurvey; setLocalSurvey: (survey: TSurvey | ((TSurvey) => TSurvey)) => void; @@ -49,7 +50,7 @@ export default function HowToSendCard({ localSurvey, setLocalSurvey, environment const options = [ { id: "web", - name: "Web App", + name: "In-App Survey", icon: ComputerDesktopIcon, description: "Embed a survey in your web app to collect responses.", comingSoon: false, @@ -59,26 +60,18 @@ export default function HowToSendCard({ localSurvey, setLocalSurvey, environment id: "link", name: "Link survey", icon: LinkIcon, - description: "Share a link to a survey page.", + description: "Share a link to a survey page or embed it in a web page or email.", comingSoon: false, alert: false, }, { id: "mobile", - name: "Mobile app", + name: "Mobile App Survey", icon: DevicePhoneMobileIcon, description: "Survey users inside a mobile app (iOS & Android).", comingSoon: true, alert: false, }, - { - id: "email", - name: "Email", - icon: EnvelopeIcon, - description: "Send email surveys to your user base with your current email provider.", - comingSoon: true, - alert: false, - }, ]; return ( @@ -117,8 +110,8 @@ export default function HowToSendCard({ localSurvey, setLocalSurvey, environment option.comingSoon ? "border-slate-200 bg-slate-50/50" : option.id === localSurvey.type - ? "border-brand-dark cursor-pointer bg-slate-50" - : "cursor-pointer bg-slate-50" + ? "border-brand-dark cursor-pointer bg-slate-50" + : "cursor-pointer bg-slate-50" )}> void; +} + +export default function ImageSurveyBg({ localSurvey, handleBgChange }: ImageSurveyBgBgProps) { + const isUrl = (str: string) => { + try { + new URL(str); + return true; + } catch (error) { + return false; + } + }; + + const fileUrl = isUrl(localSurvey?.styling?.background?.bg ?? "") + ? localSurvey?.styling?.background?.bg ?? "" + : ""; + + return ( +
+
+ { + if (url.length > 0) { + handleBgChange(url[0], "image"); + } else { + handleBgChange("#ffff", "color"); + } + }} + fileUrl={fileUrl} + /> +
+
+ ); +} diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/LogicEditor.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/LogicEditor.tsx index 029a526b97..db469491f7 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/LogicEditor.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/LogicEditor.tsx @@ -1,5 +1,16 @@ -import { LogicCondition, QuestionType } from "@formbricks/types/questions"; -import { TSurvey, TSurveyLogic, TSurveyQuestion } from "@formbricks/types/v1/surveys"; +import { QuestionMarkCircleIcon, TrashIcon } from "@heroicons/react/24/solid"; +import { ChevronDown, SplitIcon } from "lucide-react"; +import { useMemo } from "react"; +import { toast } from "react-hot-toast"; +import { BsArrowDown, BsArrowReturnRight } from "react-icons/bs"; + +import { + TSurvey, + TSurveyLogic, + TSurveyLogicCondition, + TSurveyQuestion, + TSurveyQuestionType, +} from "@formbricks/types/surveys"; import { Button } from "@formbricks/ui/Button"; import { DropdownMenu, @@ -10,11 +21,6 @@ import { import { Label } from "@formbricks/ui/Label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@formbricks/ui/Select"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@formbricks/ui/Tooltip"; -import { QuestionMarkCircleIcon, TrashIcon } from "@heroicons/react/24/solid"; -import { ChevronDown, SplitIcon } from "lucide-react"; -import { useMemo } from "react"; -import { toast } from "react-hot-toast"; -import { BsArrowDown, BsArrowReturnRight } from "react-icons/bs"; interface LogicEditorProps { localSurvey: TSurvey; @@ -24,7 +30,7 @@ interface LogicEditorProps { } type LogicConditions = { - [K in LogicCondition]: { + [K in TSurveyLogicCondition]: { label: string; values: string[] | null; unique?: boolean; @@ -43,7 +49,7 @@ export default function LogicEditor({ return question.choices.map((choice) => choice.label); } else if ("range" in question) { return Array.from({ length: question.range ? question.range : 0 }, (_, i) => (i + 1).toString()); - } else if (question.type === QuestionType.NPS) { + } else if (question.type === TSurveyQuestionType.NPS) { return Array.from({ length: 11 }, (_, i) => (i + 0).toString()); } return []; @@ -75,6 +81,9 @@ export default function LogicEditor({ ], cta: ["clicked", "skipped"], consent: ["skipped", "accepted"], + pictureSelection: ["submitted", "skipped"], + fileUpload: ["uploaded", "notUploaded"], + cal: ["skipped", "booked"], }; const logicConditions: LogicConditions = { @@ -132,6 +141,21 @@ export default function LogicEditor({ values: questionValues, multiSelect: true, }, + uploaded: { + label: "has uploaded file", + values: null, + unique: true, + }, + notUploaded: { + label: "has not uploaded file", + values: null, + unique: true, + }, + booked: { + label: "has a call booked", + values: null, + unique: true, + }, }; const addLogic = () => { @@ -236,7 +260,7 @@ export default function LogicEditor({ {conditions[question.type].map( (condition) => - !(question.required && condition === "skipped") && ( + !(question.required && (condition === "skipped" || condition === "notUploaded")) && ( {logic.condition && logicConditions[logic.condition].values != null && ( -
+
{!logicConditions[logic.condition].multiSelect ? ( ) : ( -
+
{logic.value?.length === 0 ? ( -

+

Select match type

) : ( -

+

{logic.value.join(", ")}

)} - +
idx !== questionIdx && ( -
-

- {idx + 1} - {question.headline} -

+
+

{question.headline}

) @@ -326,7 +353,7 @@ export default function LogicEditor({ deleteLogic(logicIdx)} />
diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/MultipleChoiceMultiForm.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/MultipleChoiceMultiForm.tsx index 42457e1d67..05316a794e 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/MultipleChoiceMultiForm.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/MultipleChoiceMultiForm.tsx @@ -1,12 +1,16 @@ -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@formbricks/ui/Select"; -import { Button } from "@formbricks/ui/Button"; -import { Label } from "@formbricks/ui/Label"; -import { Input } from "@formbricks/ui/Input"; +"use client"; + +import QuestionFormInput from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionFormInput"; import { PlusIcon, TrashIcon } from "@heroicons/react/24/solid"; import { createId } from "@paralleldrive/cuid2"; -import { cn } from "@formbricks/lib/cn"; import { useEffect, useRef, useState } from "react"; -import { TSurveyMultipleChoiceMultiQuestion, TSurvey } from "@formbricks/types/v1/surveys"; + +import { cn } from "@formbricks/lib/cn"; +import { TSurvey, TSurveyMultipleChoiceMultiQuestion } from "@formbricks/types/surveys"; +import { Button } from "@formbricks/ui/Button"; +import { Input } from "@formbricks/ui/Input"; +import { Label } from "@formbricks/ui/Label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@formbricks/ui/Select"; interface OpenQuestionFormProps { localSurvey: TSurvey; @@ -22,6 +26,7 @@ export default function MultipleChoiceMultiForm({ questionIdx, updateQuestion, isInValid, + localSurvey, }: OpenQuestionFormProps): JSX.Element { const lastChoiceRef = useRef(null); const [isNew, setIsNew] = useState(true); @@ -155,21 +160,18 @@ export default function MultipleChoiceMultiForm({ } }, [isNew]); + const environmentId = localSurvey.environmentId; + return (
-
- -
- updateQuestion(questionIdx, { headline: e.target.value })} - isInvalid={isInValid && question.headline.trim() === ""} - /> -
-
+
{showSubheader && ( diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/MultipleChoiceSingleForm.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/MultipleChoiceSingleForm.tsx index 94999a30dc..ee7215c6b0 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/MultipleChoiceSingleForm.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/MultipleChoiceSingleForm.tsx @@ -1,12 +1,16 @@ -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@formbricks/ui/Select"; -import { Button } from "@formbricks/ui/Button"; -import { Label } from "@formbricks/ui/Label"; -import { Input } from "@formbricks/ui/Input"; -import { TrashIcon, PlusIcon } from "@heroicons/react/24/solid"; +"use client"; + +import QuestionFormInput from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionFormInput"; +import { PlusIcon, TrashIcon } from "@heroicons/react/24/solid"; import { createId } from "@paralleldrive/cuid2"; -import { cn } from "@formbricks/lib/cn"; import { useEffect, useRef, useState } from "react"; -import { TSurveyMultipleChoiceSingleQuestion, TSurvey } from "@formbricks/types/v1/surveys"; + +import { cn } from "@formbricks/lib/cn"; +import { TSurvey, TSurveyMultipleChoiceSingleQuestion } from "@formbricks/types/surveys"; +import { Button } from "@formbricks/ui/Button"; +import { Input } from "@formbricks/ui/Input"; +import { Label } from "@formbricks/ui/Label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@formbricks/ui/Select"; interface OpenQuestionFormProps { localSurvey: TSurvey; @@ -22,6 +26,7 @@ export default function MultipleChoiceSingleForm({ questionIdx, updateQuestion, isInValid, + localSurvey, }: OpenQuestionFormProps): JSX.Element { const lastChoiceRef = useRef(null); const [isNew, setIsNew] = useState(true); @@ -155,21 +160,18 @@ export default function MultipleChoiceSingleForm({ } }, [isNew]); + const environmentId = localSurvey.environmentId; + return ( -
- -
- updateQuestion(questionIdx, { headline: e.target.value })} - isInvalid={isInValid && question.headline.trim() === ""} - /> -
-
+
{showSubheader && ( diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/NPSQuestionForm.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/NPSQuestionForm.tsx index 7148a0a38b..f02d267d58 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/NPSQuestionForm.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/NPSQuestionForm.tsx @@ -1,9 +1,13 @@ -import { TSurvey, TSurveyNPSQuestion } from "@formbricks/types/v1/surveys"; +"use client"; + +import QuestionFormInput from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionFormInput"; +import { PlusIcon, TrashIcon } from "@heroicons/react/24/solid"; +import { useState } from "react"; + +import { TSurvey, TSurveyNPSQuestion } from "@formbricks/types/surveys"; import { Button } from "@formbricks/ui/Button"; import { Input } from "@formbricks/ui/Input"; import { Label } from "@formbricks/ui/Label"; -import { PlusIcon, TrashIcon } from "@heroicons/react/24/solid"; -import { useState } from "react"; interface NPSQuestionFormProps { localSurvey: TSurvey; @@ -20,24 +24,20 @@ export default function NPSQuestionForm({ updateQuestion, lastQuestion, isInValid, + localSurvey, }: NPSQuestionFormProps): JSX.Element { const [showSubheader, setShowSubheader] = useState(!!question.subheader); + const environmentId = localSurvey.environmentId; return ( -
- -
- updateQuestion(questionIdx, { headline: e.target.value })} - isInvalid={isInValid && question.headline.trim() === ""} - /> -
-
+
{showSubheader && ( diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/OpenQuestionForm.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/OpenQuestionForm.tsx index 65c4b1b46c..138e110e89 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/OpenQuestionForm.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/OpenQuestionForm.tsx @@ -1,21 +1,32 @@ -import { - TSurveyOpenTextQuestion, - TSurveyOpenTextQuestionInputType, - TSurvey, -} from "@formbricks/types/v1/surveys"; -import { QuestionTypeSelector } from "@formbricks/ui/QuestionTypeSelector"; -import { Button } from "@formbricks/ui/Button"; -import { Label } from "@formbricks/ui/Label"; -import { Input } from "@formbricks/ui/Input"; +"use client"; + +import QuestionFormInput from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionFormInput"; import { PlusIcon, TrashIcon } from "@heroicons/react/24/solid"; +import { + ChatBubbleBottomCenterTextIcon, + EnvelopeIcon, + HashtagIcon, + LinkIcon, + PhoneIcon, +} from "@heroicons/react/24/solid"; import { useState } from "react"; +import { + TSurvey, + TSurveyOpenTextQuestion, + TSurveyOpenTextQuestionInputType, +} from "@formbricks/types/surveys"; +import { Button } from "@formbricks/ui/Button"; +import { Input } from "@formbricks/ui/Input"; +import { Label } from "@formbricks/ui/Label"; +import { OptionsSwitcher } from "@formbricks/ui/QuestionTypeSelector"; + const questionTypes = [ - { value: "text", label: "Text" }, - { value: "email", label: "Email" }, - { value: "url", label: "URL" }, - { value: "number", label: "Number" }, - { value: "phone", label: "Phone" }, + { value: "text", label: "Text", icon: }, + { value: "email", label: "Email", icon: }, + { value: "url", label: "URL", icon: }, + { value: "number", label: "Number", icon: }, + { value: "phone", label: "Phone", icon: }, ]; interface OpenQuestionFormProps { @@ -32,6 +43,7 @@ export default function OpenQuestionForm({ questionIdx, updateQuestion, isInValid, + localSurvey, }: OpenQuestionFormProps): JSX.Element { const [showSubheader, setShowSubheader] = useState(!!question.subheader); const defaultPlaceholder = getPlaceholderByInputType(question.inputType ?? "text"); @@ -45,21 +57,17 @@ export default function OpenQuestionForm({ updateQuestion(questionIdx, updatedAttributes); }; + const environmentId = localSurvey.environmentId; + return ( -
- -
- updateQuestion(questionIdx, { headline: e.target.value })} - isInvalid={isInValid && question.headline.trim() === ""} - /> -
-
+
{showSubheader && ( @@ -106,9 +114,9 @@ export default function OpenQuestionForm({
-
diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/PictureSelectionForm.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/PictureSelectionForm.tsx new file mode 100644 index 0000000000..171f05fda3 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/PictureSelectionForm.tsx @@ -0,0 +1,114 @@ +import QuestionFormInput from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionFormInput"; +import { PlusIcon, TrashIcon } from "@heroicons/react/24/solid"; +import { createId } from "@paralleldrive/cuid2"; +import { useState } from "react"; + +import { cn } from "@formbricks/lib/cn"; +import { TSurvey, TSurveyPictureSelectionQuestion } from "@formbricks/types/surveys"; +import { Button } from "@formbricks/ui/Button"; +import FileInput from "@formbricks/ui/FileInput"; +import { Input } from "@formbricks/ui/Input"; +import { Label } from "@formbricks/ui/Label"; +import { Switch } from "@formbricks/ui/Switch"; + +interface PictureSelectionFormProps { + localSurvey: TSurvey; + question: TSurveyPictureSelectionQuestion; + questionIdx: number; + updateQuestion: (questionIdx: number, updatedAttributes: any) => void; + lastQuestion: boolean; + isInValid: boolean; +} + +export default function PictureSelectionForm({ + localSurvey, + question, + questionIdx, + updateQuestion, + isInValid, +}: PictureSelectionFormProps): JSX.Element { + const [showSubheader, setShowSubheader] = useState(!!question.subheader); + const environmentId = localSurvey.environmentId; + + return ( + + +
+ {showSubheader && ( + <> + +
+ updateQuestion(questionIdx, { subheader: e.target.value })} + /> + { + setShowSubheader(false); + updateQuestion(questionIdx, { subheader: "" }); + }} + /> +
+ + )} + {!showSubheader && ( + + )} +
+
+ +
+ { + updateQuestion(questionIdx, { + choices: urls.map((url) => ({ imageUrl: url, id: createId() })), + }); + }} + fileUrl={question?.choices?.map((choice) => choice.imageUrl)} + multiple={true} + /> +
+
+ +
+ { + e.stopPropagation(); + updateQuestion(questionIdx, { allowMulti: !question.allowMulti }); + }} + /> + +
+ + ); +} diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/Placement.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/Placement.tsx index 70d7476f10..2a734e6c47 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/Placement.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/Placement.tsx @@ -1,10 +1,12 @@ "use client"; -import { cn } from "@formbricks/lib/cn"; -import { RadioGroup, RadioGroupItem } from "@formbricks/ui/RadioGroup"; -import { Label } from "@formbricks/ui/Label"; import { getPlacementStyle } from "@/app/lib/preview"; -import { PlacementType } from "@formbricks/types/js"; + +import { cn } from "@formbricks/lib/cn"; +import { TPlacement } from "@formbricks/types/common"; +import { Label } from "@formbricks/ui/Label"; +import { RadioGroup, RadioGroupItem } from "@formbricks/ui/RadioGroup"; + const placements = [ { name: "Bottom Right", value: "bottomRight", disabled: false }, { name: "Top Right", value: "topRight", disabled: false }, @@ -14,12 +16,12 @@ const placements = [ ]; type TPlacementProps = { - currentPlacement: PlacementType; - setCurrentPlacement: (placement: PlacementType) => void; + currentPlacement: TPlacement; + setCurrentPlacement: (placement: TPlacement) => void; setOverlay: (overlay: string) => void; overlay: string; - setClickOutside: (clickOutside: boolean) => void; - clickOutside: boolean; + setClickOutsideClose: (clickOutside: boolean) => void; + clickOutsideClose: boolean; }; export default function Placement({ @@ -27,13 +29,13 @@ export default function Placement({ currentPlacement, setOverlay, overlay, - setClickOutside, - clickOutside, + setClickOutsideClose, + clickOutsideClose, }: TPlacementProps) { return ( <>
- setCurrentPlacement(e as PlacementType)} value={currentPlacement}> + setCurrentPlacement(e as TPlacement)} value={currentPlacement}> {placements.map((placement) => (
@@ -78,8 +80,8 @@ export default function Placement({
setClickOutside(value === "allow")} - value={clickOutside ? "allow" : "disallow"} + onValueChange={(value) => setClickOutsideClose(value === "allow")} + value={clickOutsideClose ? "allow" : "disallow"} className="flex space-x-4">
diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionCard.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionCard.tsx index 154daccf37..9a5ed4a385 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionCard.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionCard.tsx @@ -1,20 +1,20 @@ "use client"; import AdvancedSettings from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/AdvancedSettings"; -import { getQuestionTypeName } from "@/app/lib/questions"; -import { cn } from "@formbricks/lib/cn"; -import { QuestionType } from "@formbricks/types/questions"; -import { TSurvey } from "@formbricks/types/v1/surveys"; -import { Input } from "@formbricks/ui/Input"; -import { Label } from "@formbricks/ui/Label"; -import { Switch } from "@formbricks/ui/Switch"; +import DateQuestionForm from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/DateQuestionForm"; +import PictureSelectionForm from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/PictureSelectionForm"; +import { getTSurveyQuestionTypeName } from "@/app/lib/questions"; import { + ArrowUpTrayIcon, + CalendarDaysIcon, ChatBubbleBottomCenterTextIcon, CheckIcon, ChevronDownIcon, ChevronRightIcon, CursorArrowRippleIcon, ListBulletIcon, + PhoneIcon, + PhotoIcon, PresentationChartBarIcon, QueueListIcon, StarIcon, @@ -22,8 +22,19 @@ import { import * as Collapsible from "@radix-ui/react-collapsible"; import { useState } from "react"; import { Draggable } from "react-beautiful-dnd"; + +import { cn } from "@formbricks/lib/cn"; +import { TProduct } from "@formbricks/types/product"; +import { TSurveyQuestionType } from "@formbricks/types/surveys"; +import { TSurvey } from "@formbricks/types/surveys"; +import { Input } from "@formbricks/ui/Input"; +import { Label } from "@formbricks/ui/Label"; +import { Switch } from "@formbricks/ui/Switch"; + import CTAQuestionForm from "./CTAQuestionForm"; +import CalQuestionForm from "./CalQuestionForm"; import ConsentQuestionForm from "./ConsentQuestionForm"; +import FileUploadQuestionForm from "./FileUploadQuestionForm"; import MultipleChoiceMultiForm from "./MultipleChoiceMultiForm"; import MultipleChoiceSingleForm from "./MultipleChoiceSingleForm"; import NPSQuestionForm from "./NPSQuestionForm"; @@ -33,6 +44,7 @@ import RatingQuestionForm from "./RatingQuestionForm"; interface QuestionCardProps { localSurvey: TSurvey; + product?: TProduct; questionIdx: number; moveQuestion: (questionIndex: number, up: boolean) => void; updateQuestion: (questionIdx: number, updatedAttributes: any) => void; @@ -72,6 +84,7 @@ export function BackButtonInput({ export default function QuestionCard({ localSurvey, + product, questionIdx, moveQuestion, updateQuestion, @@ -86,6 +99,14 @@ export default function QuestionCard({ const open = activeQuestionId === question.id; const [openAdvanced, setOpenAdvanced] = useState(question.logic && question.logic.length > 0); + const updateEmptyNextButtonLabels = (labelValue: string) => { + localSurvey.questions.forEach((q, index) => { + if (!q.buttonLabel || q.buttonLabel?.trim() === "") { + updateQuestion(index, { buttonLabel: labelValue }); + } + }); + }; + return ( {(provided) => ( @@ -121,25 +142,33 @@ export default function QuestionCard({
- {question.type === QuestionType.OpenText ? ( + {question.type === TSurveyQuestionType.FileUpload ? ( + + ) : question.type === TSurveyQuestionType.OpenText ? ( - ) : question.type === QuestionType.MultipleChoiceSingle ? ( + ) : question.type === TSurveyQuestionType.MultipleChoiceSingle ? ( - ) : question.type === QuestionType.MultipleChoiceMulti ? ( + ) : question.type === TSurveyQuestionType.MultipleChoiceMulti ? ( - ) : question.type === QuestionType.NPS ? ( + ) : question.type === TSurveyQuestionType.NPS ? ( - ) : question.type === QuestionType.CTA ? ( + ) : question.type === TSurveyQuestionType.CTA ? ( - ) : question.type === QuestionType.Rating ? ( + ) : question.type === TSurveyQuestionType.Rating ? ( - ) : question.type === "consent" ? ( + ) : question.type === TSurveyQuestionType.Consent ? ( + ) : question.type === TSurveyQuestionType.PictureSelection ? ( + + ) : question.type === TSurveyQuestionType.Date ? ( + + ) : question.type === TSurveyQuestionType.Cal ? ( + ) : null}

- {question.headline || getQuestionTypeName(question.type)} + {question.headline || getTSurveyQuestionTypeName(question.type)}

{!open && question?.required && (

@@ -161,7 +190,7 @@ export default function QuestionCard({

- {question.type === QuestionType.OpenText ? ( + {question.type === TSurveyQuestionType.OpenText ? ( - ) : question.type === QuestionType.MultipleChoiceSingle ? ( + ) : question.type === TSurveyQuestionType.MultipleChoiceSingle ? ( - ) : question.type === QuestionType.MultipleChoiceMulti ? ( + ) : question.type === TSurveyQuestionType.MultipleChoiceMulti ? ( - ) : question.type === QuestionType.NPS ? ( + ) : question.type === TSurveyQuestionType.NPS ? ( - ) : question.type === QuestionType.CTA ? ( + ) : question.type === TSurveyQuestionType.CTA ? ( - ) : question.type === QuestionType.Rating ? ( + ) : question.type === TSurveyQuestionType.Rating ? ( - ) : question.type === "consent" ? ( + ) : question.type === TSurveyQuestionType.Consent ? ( + ) : question.type === TSurveyQuestionType.Date ? ( + + ) : question.type === TSurveyQuestionType.PictureSelection ? ( + + ) : question.type === TSurveyQuestionType.FileUpload ? ( + + ) : question.type === TSurveyQuestionType.Cal ? ( + ) : null}
- + {openAdvanced ? ( ) : ( @@ -236,12 +301,12 @@ export default function QuestionCard({ - {question.type !== QuestionType.NPS && - question.type !== QuestionType.Rating && - question.type !== QuestionType.CTA ? ( + {question.type !== TSurveyQuestionType.NPS && + question.type !== TSurveyQuestionType.Rating && + question.type !== TSurveyQuestionType.CTA ? (
- +
{ - if (e.target.value.trim() == "") e.target.value = ""; updateQuestion(questionIdx, { buttonLabel: e.target.value }); }} + onBlur={(e) => { + updateEmptyNextButtonLabels(e.target.value); + }} />
@@ -267,7 +334,8 @@ export default function QuestionCard({ )}
) : null} - {(question.type === QuestionType.Rating || question.type === QuestionType.NPS) && + {(question.type === TSurveyQuestionType.Rating || + question.type === TSurveyQuestionType.NPS) && questionIdx !== 0 && (
void; + isInValid: boolean; + environmentId: string; + ref?: RefObject; +} + +const QuestionFormInput = ({ + question, + questionIdx, + updateQuestion, + isInValid, + environmentId, + ref, +}: QuestionFormInputProps) => { + const [showImageUploader, setShowImageUploader] = useState(!!question.imageUrl); + + return ( +
+ +
+ {showImageUploader && ( + { + updateQuestion(questionIdx, { imageUrl: url[0] }); + }} + fileUrl={question.imageUrl} + /> + )} +
+ updateQuestion(questionIdx, { headline: e.target.value })} + isInvalid={isInValid && question.headline.trim() === ""} + /> + setShowImageUploader((prev) => !prev)} + /> +
+
+
+ ); +}; + +export default QuestionFormInput; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionMenu.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionMenu.tsx index 57e394fba9..b75b98d3b8 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionMenu.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionMenu.tsx @@ -1,6 +1,6 @@ "use client"; -import { ArrowUpIcon, ArrowDownIcon, TrashIcon, DocumentDuplicateIcon } from "@heroicons/react/24/solid"; +import { ArrowDownIcon, ArrowUpIcon, DocumentDuplicateIcon, TrashIcon } from "@heroicons/react/24/solid"; interface QuestionDropdownProps { questionIdx: number; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionsSettingsTabs.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionsSettingsTabs.tsx index 196c406102..d579f7e786 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionsSettingsTabs.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionsSettingsTabs.tsx @@ -1,6 +1,7 @@ -import { cn } from "@formbricks/lib/cn"; import { Cog8ToothIcon, QueueListIcon } from "@heroicons/react/24/solid"; +import { cn } from "@formbricks/lib/cn"; + interface Tab { id: "questions" | "settings"; label: string; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionsView.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionsView.tsx index 5ed28f6af4..197e721a5c 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionsView.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionsView.tsx @@ -1,12 +1,14 @@ "use client"; import HiddenFieldsCard from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/HiddenFieldsCard"; -import { TProduct } from "@formbricks/types/v1/product"; -import { TSurveyQuestion, TSurvey } from "@formbricks/types/v1/surveys"; import { createId } from "@paralleldrive/cuid2"; import { useMemo, useState } from "react"; import { DragDropContext } from "react-beautiful-dnd"; import toast from "react-hot-toast"; + +import { TProduct } from "@formbricks/types/product"; +import { TSurvey, TSurveyQuestion } from "@formbricks/types/surveys"; + import AddQuestionButton from "./AddQuestionButton"; import EditThankYouCard from "./EditThankYouCard"; import EditWelcomeCard from "./EditWelcomeCard"; @@ -107,21 +109,18 @@ export default function QuestionsView({ const deleteQuestion = (questionIdx: number) => { const questionId = localSurvey.questions[questionIdx].id; + const activeQuestionIdTemp = activeQuestionId ?? localSurvey.questions[0].id; let updatedSurvey: TSurvey = { ...localSurvey }; updatedSurvey.questions.splice(questionIdx, 1); - updatedSurvey = handleQuestionLogicChange(updatedSurvey, questionId, "end"); setLocalSurvey(updatedSurvey); delete internalQuestionIdMap[questionId]; - - if (questionId === activeQuestionId) { - if (questionIdx < localSurvey.questions.length - 1) { - setActiveQuestionId(localSurvey.questions[questionIdx + 1].id); + if (questionId === activeQuestionIdTemp) { + if (questionIdx <= localSurvey.questions.length && localSurvey.questions.length > 0) { + setActiveQuestionId(localSurvey.questions[questionIdx % localSurvey.questions.length].id); } else if (localSurvey.thankYouCard.enabled) { - setActiveQuestionId("thank-you-card"); - } else { - setActiveQuestionId(localSurvey.questions[questionIdx - 1].id); + setActiveQuestionId("end"); } } toast.success("Question deleted."); @@ -202,6 +201,7 @@ export default function QuestionsView({ -
- -
- updateQuestion(questionIdx, { headline: e.target.value })} - isInvalid={isInValid && question.headline.trim() === ""} - /> -
-
+
{showSubheader && ( diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/RatingTypeDropdown.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/RatingTypeDropdown.tsx index 908704738b..2c7e2bc6e0 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/RatingTypeDropdown.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/RatingTypeDropdown.tsx @@ -1,6 +1,6 @@ -import React, { useEffect, useState } from "react"; import { ChevronDownIcon } from "@heroicons/react/24/solid"; import * as DropdownMenu from "@radix-ui/react-dropdown-menu"; +import React, { useEffect, useState } from "react"; type Option = { label: string; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/RecontactOptionsCard.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/RecontactOptionsCard.tsx index 0fe61d1352..99cf7dfa82 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/RecontactOptionsCard.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/RecontactOptionsCard.tsx @@ -1,16 +1,17 @@ "use client"; +import { CheckCircleIcon } from "@heroicons/react/24/solid"; +import * as Collapsible from "@radix-ui/react-collapsible"; +import Link from "next/link"; +import { useEffect, useState } from "react"; + import { cn } from "@formbricks/lib/cn"; -import { TSurvey } from "@formbricks/types/v1/surveys"; +import { TSurvey } from "@formbricks/types/surveys"; import { AdvancedOptionToggle } from "@formbricks/ui/AdvancedOptionToggle"; import { Badge } from "@formbricks/ui/Badge"; import { Input } from "@formbricks/ui/Input"; import { Label } from "@formbricks/ui/Label"; import { RadioGroup, RadioGroupItem } from "@formbricks/ui/RadioGroup"; -import { CheckCircleIcon } from "@heroicons/react/24/solid"; -import * as Collapsible from "@radix-ui/react-collapsible"; -import Link from "next/link"; -import { useEffect, useState } from "react"; interface DisplayOption { id: "displayOnce" | "displayMultiple" | "respondMultiple"; @@ -121,8 +122,7 @@ export default function RecontactOptionsCard({ className="flex flex-col space-y-3" onValueChange={(v) => { if (v === "displayOnce" || v === "displayMultiple" || v === "respondMultiple") { - const updatedSurvey = { ...localSurvey, displayOption: v }; - // @ts-ignore + const updatedSurvey: TSurvey = { ...localSurvey, displayOption: v }; setLocalSurvey(updatedSurvey); } }}> diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/ResponseOptionsCard.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/ResponseOptionsCard.tsx index 80cee0b239..172a188ba0 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/ResponseOptionsCard.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/ResponseOptionsCard.tsx @@ -1,28 +1,26 @@ "use client"; -import { TSurvey } from "@formbricks/types/v1/surveys"; -import { AdvancedOptionToggle } from "@formbricks/ui/AdvancedOptionToggle"; -import { DatePicker } from "@formbricks/ui/DatePicker"; -import { Input } from "@formbricks/ui/Input"; -import { Label } from "@formbricks/ui/Label"; -import { Switch } from "@formbricks/ui/Switch"; -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@formbricks/ui/Tooltip"; import { CheckCircleIcon } from "@heroicons/react/24/solid"; import * as Collapsible from "@radix-ui/react-collapsible"; import { KeyboardEventHandler, useEffect, useState } from "react"; import toast from "react-hot-toast"; +import { TSurvey } from "@formbricks/types/surveys"; +import { AdvancedOptionToggle } from "@formbricks/ui/AdvancedOptionToggle"; +import { DatePicker } from "@formbricks/ui/DatePicker"; +import { Input } from "@formbricks/ui/Input"; +import { Label } from "@formbricks/ui/Label"; +import { Switch } from "@formbricks/ui/Switch"; + interface ResponseOptionsCardProps { localSurvey: TSurvey; setLocalSurvey: (survey: TSurvey | ((TSurvey) => TSurvey)) => void; - isEncryptionKeySet: boolean; responseCount: number; } export default function ResponseOptionsCard({ localSurvey, setLocalSurvey, - isEncryptionKeySet, responseCount, }: ResponseOptionsCardProps) { const [open, setOpen] = useState(false); @@ -44,7 +42,7 @@ export default function ResponseOptionsCard({ subheading: "You can only use this link once.", }); - const [singleUseEncryption, setSingleUseEncryption] = useState(isEncryptionKeySet); + const [singleUseEncryption, setSingleUseEncryption] = useState(true); const [verifyEmailSurveyDetails, setVerifyEmailSurveyDetails] = useState({ name: "", subheading: "", @@ -53,7 +51,7 @@ export default function ResponseOptionsCard({ const isPinProtectionEnabled = localSurvey.pin !== null; - const [verifyProtectWithPinError, setverifyProtectWithPinError] = useState(null); + const [verifyProtectWithPinError, setVerifyProtectWithPinError] = useState(null); const handleRedirectCheckMark = () => { setRedirectToggle((prev) => !prev); @@ -80,24 +78,25 @@ export default function ResponseOptionsCard({ }; const handleProtectSurveyWithPinToggle = () => { - setLocalSurvey((prevSurvey) => ({ ...prevSurvey, pin: isPinProtectionEnabled ? null : 1234 })); + setLocalSurvey((prevSurvey) => ({ ...prevSurvey, pin: isPinProtectionEnabled ? null : "1234" })); }; const handleProtectSurveyPinChange = (pin: string) => { - const pinAsNumber = Number(pin); - - if (isNaN(pinAsNumber)) return toast.error("PIN can only contain numbers"); - setLocalSurvey({ ...localSurvey, pin: pinAsNumber }); + //check if pin only contains numbers + const validation = /^\d+$/; + const isValidPin = validation.test(pin); + if (!isValidPin) return toast.error("PIN can only contain numbers"); + setLocalSurvey({ ...localSurvey, pin }); }; const handleProtectSurveyPinBlurEvent = () => { - if (!localSurvey.pin) return setverifyProtectWithPinError(null); + if (!localSurvey.pin) return setVerifyProtectWithPinError(null); const regexPattern = /^\d{4}$/; const isValidPin = regexPattern.test(`${localSurvey.pin}`); - if (!isValidPin) return setverifyProtectWithPinError("PIN must be a four digit number."); - setverifyProtectWithPinError(null); + if (!isValidPin) return setVerifyProtectWithPinError("PIN must be a four digit number."); + setVerifyProtectWithPinError(null); }; const handleSurveyPinInputKeyDown: KeyboardEventHandler = (e) => { @@ -438,34 +437,20 @@ export default function ResponseOptionsCard({ />
- - - -
- - -
-
- {!isEncryptionKeySet && ( - -

- FORMBRICKS_ENCRYPTION_KEY needs to be set to enable this feature. -

-
- )} -
-
+
+ + +
@@ -520,11 +505,10 @@ export default function ResponseOptionsCard({ void; actionClasses: TActionClass[]; attributeClasses: TAttributeClass[]; - isEncryptionKeySet: boolean; responseCount: number; + membershipRole?: TMembershipRole; + colours: string[]; } export default function SettingsView({ @@ -25,8 +28,9 @@ export default function SettingsView({ setLocalSurvey, actionClasses, attributeClasses, - isEncryptionKeySet, responseCount, + membershipRole, + colours, }: SettingsViewProps) { return (
@@ -44,12 +48,12 @@ export default function SettingsView({ setLocalSurvey={setLocalSurvey} environmentId={environment.id} actionClasses={actionClasses} + membershipRole={membershipRole} /> @@ -59,7 +63,7 @@ export default function SettingsView({ environmentId={environment.id} /> - +
); } diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/StylingCard.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/StylingCard.tsx index 50ff77710f..f76b41846f 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/StylingCard.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/StylingCard.tsx @@ -1,25 +1,38 @@ "use client"; -import { PlacementType } from "@formbricks/types/js"; -import { TSurvey } from "@formbricks/types/v1/surveys"; -import { ColorPicker } from "@formbricks/ui/ColorPicker"; -import { Label } from "@formbricks/ui/Label"; -import { Switch } from "@formbricks/ui/Switch"; import { CheckCircleIcon } from "@heroicons/react/24/solid"; import * as Collapsible from "@radix-ui/react-collapsible"; import { useState } from "react"; + +import { TPlacement } from "@formbricks/types/common"; +import { TSurvey, TSurveyBackgroundBgType } from "@formbricks/types/surveys"; +import { ColorPicker } from "@formbricks/ui/ColorPicker"; +import { Label } from "@formbricks/ui/Label"; +import { Switch } from "@formbricks/ui/Switch"; + import Placement from "./Placement"; +import SurveyBgSelectorTab from "./SurveyBgSelectorTab"; interface StylingCardProps { localSurvey: TSurvey; setLocalSurvey: React.Dispatch>; + colours: string[]; } -export default function StylingCard({ localSurvey, setLocalSurvey }: StylingCardProps) { +export default function StylingCard({ localSurvey, setLocalSurvey, colours }: StylingCardProps) { const [open, setOpen] = useState(false); - const { type, productOverwrites } = localSurvey; - const { brandColor, clickOutside, darkOverlay, placement, highlightBorderColor } = productOverwrites ?? {}; + const { type, productOverwrites, styling } = localSurvey; + const { brandColor, clickOutsideClose, darkOverlay, placement, highlightBorderColor } = + productOverwrites ?? {}; + const { bg, bgType, brightness } = styling?.background ?? {}; + + const [inputValue, setInputValue] = useState(100); + + const handleInputChange = (e) => { + setInputValue(e.target.value); + handleBrightnessChange(parseInt(e.target.value)); + }; const togglePlacement = () => { setLocalSurvey({ @@ -27,6 +40,8 @@ export default function StylingCard({ localSurvey, setLocalSurvey }: StylingCard productOverwrites: { ...localSurvey.productOverwrites, placement: !!placement ? null : "bottomRight", + clickOutsideClose: false, + darkOverlay: false, }, }); }; @@ -41,6 +56,34 @@ export default function StylingCard({ localSurvey, setLocalSurvey }: StylingCard }); }; + const toggleBackgroundColor = () => { + setLocalSurvey({ + ...localSurvey, + styling: { + ...localSurvey.styling, + background: { + ...localSurvey.styling?.background, + bg: !!bg ? undefined : "#ffff", + bgType: !!bg ? undefined : "color", + }, + }, + }); + }; + + const toggleBrightness = () => { + setLocalSurvey({ + ...localSurvey, + styling: { + ...localSurvey.styling, + background: { + ...localSurvey.styling?.background, + brightness: !!brightness ? undefined : 100, + }, + }, + }); + setInputValue(100); + }; + const toggleHighlightBorderColor = () => { setLocalSurvey({ ...localSurvey, @@ -61,6 +104,35 @@ export default function StylingCard({ localSurvey, setLocalSurvey }: StylingCard }); }; + const handleBgChange = (color: string, type: TSurveyBackgroundBgType) => { + setInputValue(100); + setLocalSurvey({ + ...localSurvey, + styling: { + ...localSurvey.styling, + background: { + ...localSurvey.styling?.background, + bg: color, + bgType: type, + brightness: undefined, + }, + }, + }); + }; + + const handleBrightnessChange = (percent: number) => { + setLocalSurvey({ + ...localSurvey, + styling: { + ...(localSurvey.styling || {}), + background: { + ...localSurvey.styling?.background, + brightness: percent, + }, + }, + }); + }; + const handleBorderColorChange = (color: string) => { setLocalSurvey({ ...localSurvey, @@ -71,7 +143,7 @@ export default function StylingCard({ localSurvey, setLocalSurvey }: StylingCard }); }; - const handlePlacementChange = (placement: PlacementType) => { + const handlePlacementChange = (placement: TPlacement) => { setLocalSurvey({ ...localSurvey, productOverwrites: { @@ -93,12 +165,12 @@ export default function StylingCard({ localSurvey, setLocalSurvey }: StylingCard }); }; - const handleClickOutside = (clickOutside: boolean) => { + const handleClickOutsideClose = (clickOutsideClose: boolean) => { setLocalSurvey({ ...localSurvey, productOverwrites: { ...localSurvey.productOverwrites, - clickOutside, + clickOutsideClose, }, }); }; @@ -142,6 +214,66 @@ export default function StylingCard({ localSurvey, setLocalSurvey }: StylingCard
)}
+ {type == "link" && ( + <> + {/* Background */} +
+
+ + +
+ {bg && ( + + )} +
+ {/* Overlay */} +
+
+ + +
+ {brightness && ( +
+
+

Transparency

+ +
+
+ )} +
+ + )} {/* positioning */} {type !== "link" && (
@@ -163,8 +295,8 @@ export default function StylingCard({ localSurvey, setLocalSurvey }: StylingCard setCurrentPlacement={handlePlacementChange} setOverlay={handleOverlay} overlay={darkOverlay ? "dark" : "light"} - setClickOutside={handleClickOutside} - clickOutside={!!clickOutside} + setClickOutsideClose={handleClickOutsideClose} + clickOutsideClose={!!clickOutsideClose} />
@@ -183,9 +315,9 @@ export default function StylingCard({ localSurvey, setLocalSurvey }: StylingCard /> diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/SurveyBgSelectorTab.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/SurveyBgSelectorTab.tsx new file mode 100644 index 0000000000..bedba88197 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/SurveyBgSelectorTab.tsx @@ -0,0 +1,63 @@ +import { useState } from "react"; + +import { TSurvey } from "@formbricks/types/surveys"; + +import AnimatedSurveyBg from "./AnimatedSurveyBg"; +import ColorSurveyBg from "./ColorSurveyBg"; +import ImageSurveyBg from "./ImageSurveyBg"; + +interface SurveyBgSelectorTabProps { + localSurvey: TSurvey; + handleBgChange: (bg: string, bgType: string) => void; + colours: string[]; + bgType: string | null | undefined; +} + +const TabButton = ({ isActive, onClick, children }) => ( + +); + +export default function SurveyBgSelectorTab({ + localSurvey, + handleBgChange, + colours, + bgType, +}: SurveyBgSelectorTabProps) { + const [tab, setTab] = useState(bgType || "image"); + + const renderContent = () => { + switch (tab) { + case "image": + return ; + case "animation": + return ; + case "color": + return ; + default: + return null; + } + }; + + return ( +
+
+ setTab("image")}> + Image + + setTab("animation")}> + Animation + + setTab("color")}> + Color + +
+ {renderContent()} +
+ ); +} diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/SurveyEditor.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/SurveyEditor.tsx index 52ae825ab1..a4b2f6396b 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/SurveyEditor.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/SurveyEditor.tsx @@ -1,18 +1,21 @@ "use client"; +import Loading from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/loading"; import React from "react"; import { useEffect, useState } from "react"; + +import { TActionClass } from "@formbricks/types/actionClasses"; +import { TAttributeClass } from "@formbricks/types/attributeClasses"; +import { TEnvironment } from "@formbricks/types/environment"; +import { TMembershipRole } from "@formbricks/types/memberships"; +import { TProduct } from "@formbricks/types/product"; +import { TSurvey } from "@formbricks/types/surveys"; + import PreviewSurvey from "../../../components/PreviewSurvey"; import QuestionsAudienceTabs from "./QuestionsSettingsTabs"; import QuestionsView from "./QuestionsView"; import SettingsView from "./SettingsView"; import SurveyMenuBar from "./SurveyMenuBar"; -import { TEnvironment } from "@formbricks/types/v1/environment"; -import { TSurvey } from "@formbricks/types/v1/surveys"; -import { TProduct } from "@formbricks/types/v1/product"; -import { TAttributeClass } from "@formbricks/types/v1/attributeClasses"; -import { TActionClass } from "@formbricks/types/v1/actionClasses"; -import { ErrorComponent } from "@formbricks/ui/ErrorComponent"; interface SurveyEditorProps { survey: TSurvey; @@ -20,8 +23,9 @@ interface SurveyEditorProps { environment: TEnvironment; actionClasses: TActionClass[]; attributeClasses: TAttributeClass[]; - isEncryptionKeySet: boolean; responseCount: number; + membershipRole?: TMembershipRole; + colours: string[]; } export default function SurveyEditor({ @@ -30,8 +34,9 @@ export default function SurveyEditor({ environment, actionClasses, attributeClasses, - isEncryptionKeySet, responseCount, + membershipRole, + colours, }: SurveyEditorProps): JSX.Element { const [activeView, setActiveView] = useState<"questions" | "settings">("questions"); const [activeQuestionId, setActiveQuestionId] = useState(null); @@ -40,7 +45,8 @@ export default function SurveyEditor({ useEffect(() => { if (survey) { - setLocalSurvey(survey); + if (localSurvey) return; + setLocalSurvey(JSON.parse(JSON.stringify(survey))); if (survey.questions.length > 0) { setActiveQuestionId(survey.questions[0].id); @@ -58,7 +64,7 @@ export default function SurveyEditor({ }, [localSurvey?.type]); if (!localSurvey) { - return ; + return ; } return ( @@ -95,8 +101,9 @@ export default function SurveyEditor({ setLocalSurvey={setLocalSurvey} actionClasses={actionClasses} attributeClasses={attributeClasses} - isEncryptionKeySet={isEncryptionKeySet} responseCount={responseCount} + membershipRole={membershipRole} + colours={colours} /> )} @@ -108,6 +115,7 @@ export default function SurveyEditor({ product={product} environment={environment} previewType={localSurvey.type === "web" ? "modal" : "fullwidth"} + onFileUpload={async (file) => file.name} />
diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/SurveyMenuBar.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/SurveyMenuBar.tsx index e754ba6cd9..115aaa9839 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/SurveyMenuBar.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/SurveyMenuBar.tsx @@ -1,21 +1,23 @@ "use client"; -import AlertDialog from "@formbricks/ui/AlertDialog"; -import { DeleteDialog } from "@formbricks/ui/DeleteDialog"; -import { QuestionType } from "@formbricks/types/questions"; -import { TEnvironment } from "@formbricks/types/v1/environment"; -import { TProduct } from "@formbricks/types/v1/product"; -import { TSurvey } from "@formbricks/types/v1/surveys"; -import { Button } from "@formbricks/ui/Button"; -import { Input } from "@formbricks/ui/Input"; +import SurveyStatusDropdown from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/SurveyStatusDropdown"; import { ArrowLeftIcon, Cog8ToothIcon, ExclamationTriangleIcon } from "@heroicons/react/24/solid"; import { isEqual } from "lodash"; import { useRouter } from "next/navigation"; import { useEffect, useState } from "react"; import toast from "react-hot-toast"; -import { validateQuestion } from "./Validation"; + +import { TEnvironment } from "@formbricks/types/environment"; +import { TProduct } from "@formbricks/types/product"; +import { TSurvey, TSurveyQuestionType } from "@formbricks/types/surveys"; +import AlertDialog from "@formbricks/ui/AlertDialog"; +import { Button } from "@formbricks/ui/Button"; +import { DeleteDialog } from "@formbricks/ui/DeleteDialog"; +import { Input } from "@formbricks/ui/Input"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@formbricks/ui/Tooltip"; + import { deleteSurveyAction, updateSurveyAction } from "../actions"; -import SurveyStatusDropdown from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/SurveyStatusDropdown"; +import { validateQuestion } from "./Validation"; interface SurveyMenuBarProps { localSurvey: TSurvey; @@ -44,7 +46,12 @@ export default function SurveyMenuBar({ const [audiencePrompt, setAudiencePrompt] = useState(true); const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false); const [isConfirmDialogOpen, setConfirmDialogOpen] = useState(false); - const [isMutatingSurvey, setIsMutatingSurvey] = useState(false); + const [isSurveyPublishing, setIsSurveyPublishing] = useState(false); + const [isSurveySaving, setIsSurveySaving] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const cautionText = "This survey received responses, make changes with caution."; + let faultyQuestions: String[] = []; useEffect(() => { @@ -82,7 +89,8 @@ export default function SurveyMenuBar({ setDeleteDialogOpen(false); router.back(); } catch (error) { - console.log("An error occurred deleting the survey"); + console.error("An error occurred deleting the survey"); + toast.error("An error occurred deleting the survey"); } }; @@ -107,6 +115,12 @@ export default function SurveyMenuBar({ return; } + let pin = survey?.pin; + if (pin !== null && pin.toString().length !== 4) { + toast.error("PIN must be a four digit number."); + return; + } + faultyQuestions = []; for (let index = 0; index < survey.questions.length; index++) { const question = survey.questions[index]; @@ -133,8 +147,8 @@ export default function SurveyMenuBar({ existingQuestionIds.add(question.id); if ( - question.type === QuestionType.MultipleChoiceSingle || - question.type === QuestionType.MultipleChoiceMulti + question.type === TSurveyQuestionType.MultipleChoiceSingle || + question.type === TSurveyQuestionType.MultipleChoiceMulti ) { const haveSameChoices = question.choices.some((element) => element.label.trim() === "") || @@ -157,19 +171,21 @@ export default function SurveyMenuBar({ if (validFields < 2) { setInvalidQuestions([question.id]); - toast.error("Incomplete logic jumps detected: Please fill or delete them."); + toast.error("Incomplete logic jumps detected: Fill or remove them in the Questions tab."); return false; } if (question.required && logic.condition === "skipped") { - toast.error("You have a missing logic condition. Please update or delete it."); + toast.error("A logic condition is missing: Please update or delete it in the Questions tab."); return false; } const thisLogic = `${logic.condition}-${logic.value}`; if (existingLogicConditions.has(thisLogic)) { setInvalidQuestions([question.id]); - toast.error("You have 2 competing logic conditons. Please update or delete one."); + toast.error( + "There are two competing logic conditons: Please update or delete one in the Questions tab." + ); return false; } existingLogicConditions.add(thisLogic); @@ -204,7 +220,7 @@ export default function SurveyMenuBar({ toast.error("Please add at least one question."); return; } - setIsMutatingSurvey(true); + setIsSurveySaving(true); // Create a copy of localSurvey with isDraft removed from every question const strippedSurvey: TSurvey = { ...localSurvey, @@ -212,17 +228,21 @@ export default function SurveyMenuBar({ const { isDraft, ...rest } = question; return rest; }), + attributeFilters: localSurvey.attributeFilters.filter((attributeFilter) => { + if (attributeFilter.attributeClassId && attributeFilter.value) { + return true; + } + }), }; if (!validateSurvey(localSurvey)) { - setIsMutatingSurvey(false); + setIsSurveySaving(false); return; } try { await updateSurveyAction({ ...strippedSurvey }); - router.refresh(); - setIsMutatingSurvey(false); + setIsSurveySaving(false); toast.success("Changes saved."); if (shouldNavigateBack) { router.back(); @@ -232,16 +252,23 @@ export default function SurveyMenuBar({ } else { router.push(`/environments/${environment.id}/surveys`); } - router.refresh(); } } catch (e) { console.error(e); - setIsMutatingSurvey(false); + setIsSurveySaving(false); toast.error(`Error saving changes`); return; } }; + function containsEmptyTriggers() { + return ( + localSurvey.type === "web" && + localSurvey.triggers && + (localSurvey.triggers[0] === "" || localSurvey.triggers.length === 0) + ); + } + return ( <> {environment?.type === "development" && ( @@ -272,11 +299,20 @@ export default function SurveyMenuBar({ />
{responseCount > 0 && ( -
- -

- This survey received responses, make changes with caution. -

+
+ + + + + + +

+ {cautionText} +

+
+
+
+

{cautionText}

)}
@@ -288,9 +324,10 @@ export default function SurveyMenuBar({ />
@@ -307,22 +344,16 @@ export default function SurveyMenuBar({ )} {localSurvey.status === "draft" && !audiencePrompt && (
diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/UpdateQuestionId.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/UpdateQuestionId.tsx index e27b3bf98e..ef2da9d5f0 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/UpdateQuestionId.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/UpdateQuestionId.tsx @@ -1,11 +1,12 @@ "use client"; -import { TSurvey, TSurveyQuestion } from "@formbricks/types/v1/surveys"; -import { Input } from "@formbricks/ui/Input"; -import { Label } from "@formbricks/ui/Label"; import { useState } from "react"; import toast from "react-hot-toast"; +import { TSurvey, TSurveyQuestion } from "@formbricks/types/surveys"; +import { Input } from "@formbricks/ui/Input"; +import { Label } from "@formbricks/ui/Label"; + interface UpdateQuestionIdProps { localSurvey: TSurvey; question: TSurveyQuestion; @@ -40,6 +41,11 @@ export default function UpdateQuestionId({ updateQuestion(questionIdx, { id: prevValue }); toast.error("ID should not be empty."); return; + } else if (["userId", "source", "suid", "end", "start", "welcomeCard", "hidden"].includes(currentValue)) { + setCurrentValue(prevValue); + updateQuestion(questionIdx, { id: prevValue }); + toast.error("Reserved words cannot be used as question ID"); + return; } else { setIsInputInvalid(false); toast.success("Question ID updated."); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/Validation.ts b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/Validation.ts index 125098f640..161527fc26 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/Validation.ts +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/Validation.ts @@ -1,11 +1,11 @@ // extend this object in order to add more validation rules - import { TSurveyConsentQuestion, TSurveyMultipleChoiceMultiQuestion, TSurveyMultipleChoiceSingleQuestion, + TSurveyPictureSelectionQuestion, TSurveyQuestion, -} from "@formbricks/types/v1/surveys"; +} from "@formbricks/types/surveys"; const validationRules = { multipleChoiceMulti: (question: TSurveyMultipleChoiceMultiQuestion) => { @@ -17,6 +17,9 @@ const validationRules = { consent: (question: TSurveyConsentQuestion) => { return question.label.trim() !== ""; }, + pictureSelection: (question: TSurveyPictureSelectionQuestion) => { + return question.choices.length >= 2; + }, defaultValidation: (question: TSurveyQuestion) => { return question.headline.trim() !== ""; }, diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/WhenToSendCard.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/WhenToSendCard.tsx index 347b3d58bc..0fa4d4c326 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/WhenToSendCard.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/WhenToSendCard.tsx @@ -1,9 +1,15 @@ "use client"; -import AddNoCodeActionModal from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/components/AddNoCodeActionModal"; +import AddNoCodeActionModal from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/components/AddActionModal"; +import { CheckCircleIcon, PlusIcon, TrashIcon } from "@heroicons/react/24/solid"; +import * as Collapsible from "@radix-ui/react-collapsible"; +import { useCallback, useEffect, useState } from "react"; + import { cn } from "@formbricks/lib/cn"; -import { TActionClass } from "@formbricks/types/v1/actionClasses"; -import { TSurvey } from "@formbricks/types/v1/surveys"; +import { getAccessFlags } from "@formbricks/lib/membership/utils"; +import { TActionClass } from "@formbricks/types/actionClasses"; +import { TMembershipRole } from "@formbricks/types/memberships"; +import { TSurvey } from "@formbricks/types/surveys"; import { AdvancedOptionToggle } from "@formbricks/ui/AdvancedOptionToggle"; import { Badge } from "@formbricks/ui/Badge"; import { Button } from "@formbricks/ui/Button"; @@ -16,14 +22,13 @@ import { SelectTrigger, SelectValue, } from "@formbricks/ui/Select"; -import { CheckCircleIcon, PlusIcon, TrashIcon } from "@heroicons/react/24/solid"; -import * as Collapsible from "@radix-ui/react-collapsible"; -import { useCallback, useEffect, useState } from "react"; + interface WhenToSendCardProps { localSurvey: TSurvey; setLocalSurvey: (survey: TSurvey) => void; environmentId: string; actionClasses: TActionClass[]; + membershipRole?: TMembershipRole; } export default function WhenToSendCard({ @@ -31,11 +36,13 @@ export default function WhenToSendCard({ localSurvey, setLocalSurvey, actionClasses, + membershipRole, }: WhenToSendCardProps) { const [open, setOpen] = useState(localSurvey.type === "web" ? true : false); const [isAddEventModalOpen, setAddEventModalOpen] = useState(false); const [activeIndex, setActiveIndex] = useState(null); const [actionClassArray, setActionClassArray] = useState(actionClasses); + const { isViewer } = getAccessFlags(membershipRole); const autoClose = localSurvey.autoClose !== null; @@ -92,6 +99,7 @@ export default function WhenToSendCard({ }; useEffect(() => { + if (isAddEventModalOpen) return; if (activeIndex !== null) { const newActionClass = actionClassArray[actionClassArray.length - 1].name; const currentActionClass = localSurvey.triggers[activeIndex]; @@ -272,6 +280,7 @@ export default function WhenToSendCard({ open={isAddEventModalOpen} setOpen={setAddEventModalOpen} setActionClassArray={setActionClassArray} + isViewer={isViewer} /> ); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/WhoToSendCard.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/WhoToSendCard.tsx index 3886e824e5..215592badb 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/WhoToSendCard.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/WhoToSendCard.tsx @@ -1,15 +1,20 @@ "use client"; +import { CheckCircleIcon, FunnelIcon, PlusIcon, TrashIcon, UserGroupIcon } from "@heroicons/react/24/solid"; +import * as Collapsible from "@radix-ui/react-collapsible"; +import { Info } from "lucide-react"; +import { useEffect, useState } from "react"; + import { cn } from "@formbricks/lib/cn"; -import { TAttributeClass } from "@formbricks/types/v1/attributeClasses"; -import { TSurvey } from "@formbricks/types/v1/surveys"; +import { TAttributeClass } from "@formbricks/types/attributeClasses"; +import { TSurvey } from "@formbricks/types/surveys"; +import { Alert, AlertDescription, AlertTitle } from "@formbricks/ui/Alert"; import { Badge } from "@formbricks/ui/Badge"; import { Button } from "@formbricks/ui/Button"; import { Input } from "@formbricks/ui/Input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@formbricks/ui/Select"; -import { CheckCircleIcon, FunnelIcon, PlusIcon, TrashIcon, UserGroupIcon } from "@heroicons/react/24/solid"; -import * as Collapsible from "@radix-ui/react-collapsible"; -import { useEffect, useState } from "react"; /* */ + +/* */ const filterConditions = [ { id: "equals", name: "equals" }, @@ -99,6 +104,24 @@ export default function WhoToSendCard({ localSurvey, setLocalSurvey, attributeCl
+
+ + + User Identification + + To target your audience you need to identify your users within your app. You can read more + about how to do this in our{" "} + + docs + + . + + +
+
{localSurvey.attributeFilters?.length === 0 ? ( diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/page.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/page.tsx index f62f478ef7..f7daa28173 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/page.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/page.tsx @@ -1,40 +1,73 @@ -export const revalidate = REVALIDATION_INTERVAL; -import React from "react"; -import { FORMBRICKS_ENCRYPTION_KEY, REVALIDATION_INTERVAL } from "@formbricks/lib/constants"; -import SurveyEditor from "./components/SurveyEditor"; -import { getSurvey } from "@formbricks/lib/survey/service"; -import { getProductByEnvironmentId } from "@formbricks/lib/product/service"; -import { getEnvironment } from "@formbricks/lib/environment/service"; +import { getServerSession } from "next-auth"; + import { getActionClasses } from "@formbricks/lib/actionClass/service"; import { getAttributeClasses } from "@formbricks/lib/attributeClass/service"; -import { ErrorComponent } from "@formbricks/ui/ErrorComponent"; +import { authOptions } from "@formbricks/lib/authOptions"; +import { colours } from "@formbricks/lib/constants"; +import { getEnvironment } from "@formbricks/lib/environment/service"; +import { getMembershipByUserIdTeamId } from "@formbricks/lib/membership/service"; +import { getAccessFlags } from "@formbricks/lib/membership/utils"; +import { getProductByEnvironmentId } from "@formbricks/lib/product/service"; import { getResponseCountBySurveyId } from "@formbricks/lib/response/service"; +import { getSurvey } from "@formbricks/lib/survey/service"; +import { getTeamByEnvironmentId } from "@formbricks/lib/team/service"; +import { ErrorComponent } from "@formbricks/ui/ErrorComponent"; + +import SurveyEditor from "./components/SurveyEditor"; + +export const generateMetadata = async ({ params }) => { + const survey = await getSurvey(params.surveyId); + return { + title: survey?.name ? `${survey?.name} | Editor` : "Editor", + }; +}; export default async function SurveysEditPage({ params }) { - const [survey, product, environment, actionClasses, attributeClasses, responseCount] = await Promise.all([ - getSurvey(params.surveyId), - getProductByEnvironmentId(params.environmentId), - getEnvironment(params.environmentId), - getActionClasses(params.environmentId), - getAttributeClasses(params.environmentId), - getResponseCountBySurveyId(params.surveyId), - ]); - const isEncryptionKeySet = !!FORMBRICKS_ENCRYPTION_KEY; - if (!survey || !environment || !actionClasses || !attributeClasses || !product) { + const [survey, product, environment, actionClasses, attributeClasses, responseCount, team, session] = + await Promise.all([ + getSurvey(params.surveyId), + getProductByEnvironmentId(params.environmentId), + getEnvironment(params.environmentId), + getActionClasses(params.environmentId), + getAttributeClasses(params.environmentId), + getResponseCountBySurveyId(params.surveyId), + getTeamByEnvironmentId(params.environmentId), + getServerSession(authOptions), + ]); + + if (!session) { + throw new Error("Session not found"); + } + + if (!team) { + throw new Error("Team not found"); + } + + const currentUserMembership = await getMembershipByUserIdTeamId(session?.user.id, team.id); + const { isViewer } = getAccessFlags(currentUserMembership?.role); + const isSurveyCreationDeletionDisabled = isViewer; + + if ( + !survey || + !environment || + !actionClasses || + !attributeClasses || + !product || + isSurveyCreationDeletionDisabled + ) { return ; } return ( - <> - - + ); } diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/actions.ts b/apps/web/app/(app)/environments/[environmentId]/surveys/actions.ts index 6035aa7e17..6b4a15f0ab 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/actions.ts +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/actions.ts @@ -1,11 +1,12 @@ "use server"; -import { authOptions } from "@formbricks/lib/authOptions"; import { getServerSession } from "next-auth"; + +import { authOptions } from "@formbricks/lib/authOptions"; import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth"; import { createSurvey } from "@formbricks/lib/survey/service"; -import { AuthorizationError } from "@formbricks/types/v1/errors"; -import { TSurveyInput } from "@formbricks/types/v1/surveys"; +import { AuthorizationError } from "@formbricks/types/errors"; +import { TSurveyInput } from "@formbricks/types/surveys"; export async function createSurveyAction(environmentId: string, surveyBody: TSurveyInput) { const session = await getServerSession(authOptions); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/components/Modal.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/components/Modal.tsx index 0bd39508f1..d8b923a766 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/components/Modal.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/components/Modal.tsx @@ -1,7 +1,8 @@ import { getPlacementStyle } from "@/app/lib/preview"; +import { ReactNode, useEffect, useMemo, useRef, useState } from "react"; + import { cn } from "@formbricks/lib/cn"; -import { PlacementType } from "@formbricks/types/js"; -import { ReactNode, useEffect, useMemo, useState, useRef } from "react"; +import { TPlacement } from "@formbricks/types/common"; export default function Modal({ children, @@ -12,19 +13,58 @@ export default function Modal({ }: { children: ReactNode; isOpen: boolean; - placement: PlacementType; + placement: TPlacement; previewMode: string; highlightBorderColor: string | null | undefined; }) { const [show, setShow] = useState(false); const modalRef = useRef(null); + const [windowWidth, setWindowWidth] = useState(window.innerWidth); + const calculateScaling = () => { + const scaleValue = (() => { + if (windowWidth > 1600) return "1"; + else if (windowWidth > 1200) return ".9"; + else if (windowWidth > 900) return ".8"; + return "0.7"; + })(); + + const getPlacementClass = (() => { + switch (placement) { + case "bottomLeft": + return "bottom left"; + case "bottomRight": + return "bottom right"; + case "topLeft": + return "top left"; + case "topRight": + return "top right"; + default: + return ""; + } + })(); + + return { + transform: `scale(${scaleValue})`, + "transform-origin": getPlacementClass, + }; + }; + const scalingClasses = calculateScaling(); + + useEffect(() => { + const handleResize = () => setWindowWidth(window.innerWidth); + window.addEventListener("resize", handleResize); + return () => window.removeEventListener("resize", handleResize); + }, []); const highlightBorderColorStyle = useMemo(() => { - if (!highlightBorderColor) return {}; + if (!highlightBorderColor) + return { + overflow: "auto", + }; return { border: `2px solid ${highlightBorderColor}`, - overflow: "hidden", + overflow: "auto", }; }, [highlightBorderColor]); @@ -45,19 +85,19 @@ export default function Modal({ ? "translate-x-0 opacity-100" : "translate-x-32 opacity-0" : previewMode === "mobile" - ? show - ? "bottom-0" - : "-bottom-full" - : ""; + ? show + ? "bottom-0" + : "-bottom-full" + : ""; return ( -
+
{children} diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/components/PreviewSurvey.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/components/PreviewSurvey.tsx index 6a217cbb53..a139d3c4f8 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/components/PreviewSurvey.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/components/PreviewSurvey.tsx @@ -2,12 +2,7 @@ import Modal from "@/app/(app)/environments/[environmentId]/surveys/components/Modal"; import TabOption from "@/app/(app)/environments/[environmentId]/surveys/components/TabOption"; - -import { SurveyInline } from "@formbricks/ui/Survey"; -import type { TEnvironment } from "@formbricks/types/v1/environment"; -import type { TProduct } from "@formbricks/types/v1/product"; -import { TSurvey } from "@formbricks/types/v1/surveys"; -import { Button } from "@formbricks/ui/Button"; +import { MediaBackground } from "@/app/s/[surveyId]/components/MediaBackground"; import { ArrowPathRoundedSquareIcon } from "@heroicons/react/24/outline"; import { ArrowsPointingInIcon, @@ -18,6 +13,13 @@ import { import { Variants, motion } from "framer-motion"; import { useEffect, useRef, useState } from "react"; +import type { TEnvironment } from "@formbricks/types/environment"; +import type { TProduct } from "@formbricks/types/product"; +import { TUploadFileConfig } from "@formbricks/types/storage"; +import { TSurvey } from "@formbricks/types/surveys"; +import { Button } from "@formbricks/ui/Button"; +import { SurveyInline } from "@formbricks/ui/Survey"; + type TPreviewType = "modal" | "fullwidth" | "email"; interface PreviewSurveyProps { @@ -27,6 +29,7 @@ interface PreviewSurveyProps { previewType?: TPreviewType; product: TProduct; environment: TEnvironment; + onFileUpload: (file: File, config?: TUploadFileConfig) => Promise; } let surveyNameTemp; @@ -64,6 +67,7 @@ export default function PreviewSurvey({ previewType, product, environment, + onFileUpload, }: PreviewSurveyProps) { const [isModalOpen, setIsModalOpen] = useState(true); const [isFullScreenPreview, setIsFullScreenPreview] = useState(false); @@ -123,10 +127,10 @@ export default function PreviewSurvey({ useEffect(() => { // close modal if there are no questions left if (survey.type === "web" && !survey.thankYouCard.enabled) { - if (activeQuestionId === "thank-you-card") { + if (activeQuestionId === "end") { setIsModalOpen(false); setTimeout(() => { - setActiveQuestionId(survey.questions[0].id); + setActiveQuestionId(survey.questions[0]?.id); setIsModalOpen(true); }, 500); } @@ -152,6 +156,20 @@ export default function PreviewSurvey({ setActiveQuestionId(survey.welcomeCard.enabled ? "start" : survey?.questions[0]?.id); } + function animationTrigger() { + let storePreviewMode = previewMode; + setPreviewMode("null"); + setTimeout(() => { + setPreviewMode(storePreviewMode); + }, 10); + } + + useEffect(() => { + if (survey.styling?.background?.bgType === "animation") { + animationTrigger(); + } + }, [survey.styling?.background?.bg]); + useEffect(() => { if (environment && environment.widgetSetupCompleted) { setWidgetSetupCompleted(true); @@ -188,12 +206,13 @@ export default function PreviewSurvey({ className="relative flex h-[95] max-h-[95%] w-5/6 items-center justify-center rounded-lg border border-slate-300 bg-slate-200"> {previewMode === "mobile" && ( <> +

+ Preview +

-
- {/* below element is use to create notch for the mobile device mockup */} -
+ {previewType === "modal" ? ( ) : ( -
-
-
- -
+
+
+
)} -
+ )} {previewMode === "desktop" && ( @@ -274,26 +292,27 @@ export default function PreviewSurvey({ survey={survey} brandColor={brandColor} activeQuestionId={activeQuestionId || undefined} - formbricksSignature={product.formbricksSignature} + isBrandingEnabled={product.linkSurveyBranding} onActiveQuestionChange={setActiveQuestionId} isRedirectDisabled={true} + onFileUpload={onFileUpload} /> ) : ( -
-
-
- -
+ +
+
-
+ )}
)} diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/components/SurveyDropDownMenu.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/components/SurveyDropDownMenu.tsx index db7ba06940..90a8f0ec9b 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/components/SurveyDropDownMenu.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/components/SurveyDropDownMenu.tsx @@ -5,17 +5,6 @@ import { deleteSurveyAction, duplicateSurveyAction, } from "@/app/(app)/environments/[environmentId]/actions"; -import { DeleteDialog } from "@formbricks/ui/DeleteDialog"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuGroup, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@formbricks/ui/DropdownMenu"; -import LoadingSpinner from "@formbricks/ui/LoadingSpinner"; -import type { TEnvironment } from "@formbricks/types/v1/environment"; -import type { TSurvey } from "@formbricks/types/v1/surveys"; import { ArrowUpOnSquareStackIcon, DocumentDuplicateIcon, @@ -30,13 +19,26 @@ import { useRouter } from "next/navigation"; import { useMemo, useState } from "react"; import toast from "react-hot-toast"; +import type { TEnvironment } from "@formbricks/types/environment"; +import type { TSurvey } from "@formbricks/types/surveys"; +import { DeleteDialog } from "@formbricks/ui/DeleteDialog"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@formbricks/ui/DropdownMenu"; +import LoadingSpinner from "@formbricks/ui/LoadingSpinner"; + interface SurveyDropDownMenuProps { environmentId: string; survey: TSurvey; environment: TEnvironment; otherEnvironment: TEnvironment; - surveyBaseUrl: string; + webAppUrl: string; singleUseId?: string; + isSurveyCreationDeletionDisabled?: boolean; } export default function SurveyDropDownMenu({ @@ -44,14 +46,15 @@ export default function SurveyDropDownMenu({ survey, environment, otherEnvironment, - surveyBaseUrl, + webAppUrl, singleUseId, + isSurveyCreationDeletionDisabled, }: SurveyDropDownMenuProps) { const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false); const [loading, setLoading] = useState(false); const router = useRouter(); - const surveyUrl = useMemo(() => surveyBaseUrl + survey.id, [survey.id, surveyBaseUrl]); + const surveyUrl = useMemo(() => webAppUrl + "/s/" + survey.id, [survey.id, webAppUrl]); const handleDeleteSurvey = async (survey) => { setLoading(true); @@ -111,47 +114,56 @@ export default function SurveyDropDownMenu({ - - - - Edit - - - - - - {environment.type === "development" ? ( - - - - ) : environment.type === "production" ? ( - - - - ) : null} + {!isSurveyCreationDeletionDisabled && ( + <> + + + + Edit + + + + + + + + )} + {!isSurveyCreationDeletionDisabled && ( + <> + {environment.type === "development" ? ( + + + + ) : environment.type === "production" ? ( + + + + ) : null} + + )} {survey.type === "link" && survey.status !== "draft" && ( <> @@ -183,27 +195,31 @@ export default function SurveyDropDownMenu({ )} - - - + {!isSurveyCreationDeletionDisabled && ( + + + + )} - handleDeleteSurvey(survey)} - text="Are you sure you want to delete this survey and all of its responses? This action cannot be undone." - /> + {!isSurveyCreationDeletionDisabled && ( + handleDeleteSurvey(survey)} + text="Are you sure you want to delete this survey and all of its responses? This action cannot be undone." + /> + )} ); } diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/components/SurveyList.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/components/SurveyList.tsx index af3a302bd9..ca3f19a087 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/components/SurveyList.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/components/SurveyList.tsx @@ -1,23 +1,44 @@ import { UsageAttributesUpdater } from "@/app/(app)/components/FormbricksClient"; import SurveyDropDownMenu from "@/app/(app)/environments/[environmentId]/surveys/components/SurveyDropDownMenu"; import SurveyStarter from "@/app/(app)/environments/[environmentId]/surveys/components/SurveyStarter"; -import { SurveyStatusIndicator } from "@formbricks/ui/SurveyStatusIndicator"; -import { SURVEY_BASE_URL } from "@formbricks/lib/constants"; +import { generateSurveySingleUseId } from "@/app/lib/singleUseSurveys"; +import { ComputerDesktopIcon, LinkIcon, PlusIcon } from "@heroicons/react/24/solid"; +import { getServerSession } from "next-auth"; +import Link from "next/link"; + +import { authOptions } from "@formbricks/lib/authOptions"; +import { WEBAPP_URL } from "@formbricks/lib/constants"; import { getEnvironment, getEnvironments } from "@formbricks/lib/environment/service"; +import { getMembershipByUserIdTeamId } from "@formbricks/lib/membership/service"; +import { getAccessFlags } from "@formbricks/lib/membership/utils"; import { getProductByEnvironmentId } from "@formbricks/lib/product/service"; import { getSurveys } from "@formbricks/lib/survey/service"; -import type { TEnvironment } from "@formbricks/types/v1/environment"; +import { getTeamByEnvironmentId } from "@formbricks/lib/team/service"; +import type { TEnvironment } from "@formbricks/types/environment"; import { Badge } from "@formbricks/ui/Badge"; -import { ComputerDesktopIcon, LinkIcon, PlusIcon } from "@heroicons/react/24/solid"; -import Link from "next/link"; -import { generateSurveySingleUseId } from "@/app/lib/singleUseSurveys"; +import { SurveyStatusIndicator } from "@formbricks/ui/SurveyStatusIndicator"; export default async function SurveysList({ environmentId }: { environmentId: string }) { + const session = await getServerSession(authOptions); const product = await getProductByEnvironmentId(environmentId); + const team = await getTeamByEnvironmentId(environmentId); + + if (!session) { + throw new Error("Session not found"); + } + if (!product) { throw new Error("Product not found"); } + if (!team) { + throw new Error("Team not found"); + } + + const currentUserMembership = await getMembershipByUserIdTeamId(session?.user.id, team.id); + const { isViewer } = getAccessFlags(currentUserMembership?.role); + const isSurveyCreationDeletionDisabled = isViewer; + const environment = await getEnvironment(environmentId); if (!environment) { throw new Error("Environment not found"); @@ -28,22 +49,31 @@ export default async function SurveysList({ environmentId }: { environmentId: st const otherEnvironment = environments.find((e) => e.type !== environment.type)!; if (surveys.length === 0) { - return ; + return ( + + ); } return ( <>
    - -
  • -
    -
    - - Create Survey + {!isSurveyCreationDeletionDisabled && ( + +
  • +
    +
    + + Create Survey +
    -
- - + + + )} {surveys .sort((a, b) => b.updatedAt?.getTime() - a.updatedAt?.getTime()) .map((survey) => { @@ -61,8 +91,8 @@ export default async function SurveysList({ environmentId }: { environmentId: st survey.type === "link" ? "Link Survey" : survey.type === "web" - ? "In-Product Survey" - : "" + ? "In-Product Survey" + : "" } type="gray" size={"tiny"} @@ -96,8 +126,9 @@ export default async function SurveysList({ environmentId }: { environmentId: st environmentId={environmentId} environment={environment} otherEnvironment={otherEnvironment!} - surveyBaseUrl={SURVEY_BASE_URL} + webAppUrl={WEBAPP_URL} singleUseId={singleUseId} + isSurveyCreationDeletionDisabled={isSurveyCreationDeletionDisabled} />
diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/components/SurveyStarter.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/components/SurveyStarter.tsx index 58c03b80a3..75511bf11c 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/components/SurveyStarter.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/components/SurveyStarter.tsx @@ -1,23 +1,29 @@ "use client"; -import { createSurveyAction } from "../actions"; + import TemplateList from "@/app/(app)/environments/[environmentId]/surveys/templates/TemplateList"; -import LoadingSpinner from "@formbricks/ui/LoadingSpinner"; -import type { TEnvironment } from "@formbricks/types/v1/environment"; -import type { TProduct } from "@formbricks/types/v1/product"; -import { TSurveyInput } from "@formbricks/types/v1/surveys"; -import { TTemplate } from "@formbricks/types/v1/templates"; import { useRouter } from "next/navigation"; import { useState } from "react"; import { toast } from "react-hot-toast"; +import type { TEnvironment } from "@formbricks/types/environment"; +import type { TProduct } from "@formbricks/types/product"; +import { TSurveyInput } from "@formbricks/types/surveys"; +import { TTemplate } from "@formbricks/types/templates"; +import { TUser } from "@formbricks/types/user"; +import LoadingSpinner from "@formbricks/ui/LoadingSpinner"; + +import { createSurveyAction } from "../actions"; + export default function SurveyStarter({ environmentId, environment, product, + user, }: { environmentId: string; environment: TEnvironment; product: TProduct; + user: TUser; }) { const [isCreateSurveyLoading, setIsCreateSurveyLoading] = useState(false); const router = useRouter(); @@ -56,6 +62,7 @@ export default function SurveyStarter({ }} environment={environment} product={product} + user={user} /> )} diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/page.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/page.tsx index 652302e22e..d2d7ca8ac1 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/page.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/page.tsx @@ -1,10 +1,9 @@ -export const revalidate = REVALIDATION_INTERVAL; +import WidgetStatusIndicator from "@/app/(app)/environments/[environmentId]/components/WidgetStatusIndicator"; +import { Metadata } from "next"; import ContentWrapper from "@formbricks/ui/ContentWrapper"; -import { REVALIDATION_INTERVAL } from "@formbricks/lib/constants"; -import { Metadata } from "next"; + import SurveysList from "./components/SurveyList"; -import WidgetStatusIndicator from "@/app/(app)/environments/[environmentId]/components/WidgetStatusIndicator"; export const metadata: Metadata = { title: "Your Surveys", diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/templates/TemplateContainer.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/templates/TemplateContainer.tsx index df6698e254..395c382b58 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/templates/TemplateContainer.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/templates/TemplateContainer.tsx @@ -1,26 +1,31 @@ "use client"; -import { useState } from "react"; -import type { TTemplate } from "@formbricks/types/v1/templates"; -import { useEffect } from "react"; import { replacePresetPlaceholders } from "@/app/lib/templates"; -import { minimalSurvey, templates } from "./templates"; +import { useState } from "react"; +import { useEffect } from "react"; + +import type { TEnvironment } from "@formbricks/types/environment"; +import type { TProduct } from "@formbricks/types/product"; +import type { TTemplate } from "@formbricks/types/templates"; +import { TUser } from "@formbricks/types/user"; +import { SearchBox } from "@formbricks/ui/SearchBox"; + import PreviewSurvey from "../components/PreviewSurvey"; import TemplateList from "./TemplateList"; -import type { TProduct } from "@formbricks/types/v1/product"; -import type { TEnvironment } from "@formbricks/types/v1/environment"; -import { SearchBox } from "@formbricks/ui/SearchBox"; +import { minimalSurvey, templates } from "./templates"; type TemplateContainerWithPreviewProps = { environmentId: string; product: TProduct; environment: TEnvironment; + user: TUser; }; export default function TemplateContainerWithPreview({ environmentId, product, environment, + user, }: TemplateContainerWithPreviewProps) { const [activeTemplate, setActiveTemplate] = useState(null); const [activeQuestionId, setActiveQuestionId] = useState(null); @@ -56,6 +61,7 @@ export default function TemplateContainerWithPreview({ environmentId={environmentId} environment={environment} product={product} + user={user} templateSearch={templateSearch ?? ""} onTemplateClick={(template) => { setActiveQuestionId(template.preset.questions[0].id); @@ -65,14 +71,14 @@ export default function TemplateContainerWithPreview({
+ ))}
diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/templates/actions.ts b/apps/web/app/(app)/environments/[environmentId]/surveys/templates/actions.ts index f852803c3f..6b4a15f0ab 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/templates/actions.ts +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/templates/actions.ts @@ -1,11 +1,12 @@ "use server"; -import { createSurvey } from "@formbricks/lib/survey/service"; -import { authOptions } from "@formbricks/lib/authOptions"; import { getServerSession } from "next-auth"; + +import { authOptions } from "@formbricks/lib/authOptions"; import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth"; -import { AuthorizationError } from "@formbricks/types/v1/errors"; -import { TSurveyInput } from "@formbricks/types/v1/surveys"; +import { createSurvey } from "@formbricks/lib/survey/service"; +import { AuthorizationError } from "@formbricks/types/errors"; +import { TSurveyInput } from "@formbricks/types/surveys"; export async function createSurveyAction(environmentId: string, surveyBody: TSurveyInput) { const session = await getServerSession(authOptions); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/templates/page.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/templates/page.tsx index 9d374f7822..e257cb006e 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/templates/page.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/templates/page.tsx @@ -1,11 +1,23 @@ -import TemplateContainerWithPreview from "./TemplateContainer"; +import { getServerSession } from "next-auth"; + +import { authOptions } from "@formbricks/lib/authOptions"; import { getEnvironment } from "@formbricks/lib/environment/service"; import { getProductByEnvironmentId } from "@formbricks/lib/product/service"; +import TemplateContainerWithPreview from "./TemplateContainer"; + export default async function SurveyTemplatesPage({ params }) { + const session = await getServerSession(authOptions); const environmentId = params.environmentId; - const environment = await getEnvironment(environmentId); - const product = await getProductByEnvironmentId(environmentId); + + const [environment, product] = await Promise.all([ + getEnvironment(environmentId), + getProductByEnvironmentId(environmentId), + ]); + + if (!session) { + throw new Error("Session not found"); + } if (!product) { throw new Error("Product not found"); @@ -16,6 +28,11 @@ export default async function SurveyTemplatesPage({ params }) { } return ( - + ); } diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/templates/templates.ts b/apps/web/app/(app)/environments/[environmentId]/surveys/templates/templates.ts index 5d2afa5b45..8e4b7a2e01 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/templates/templates.ts +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/templates/templates.ts @@ -1,8 +1,13 @@ -import { QuestionType } from "@formbricks/types/questions"; -import { TSurvey, TSurveyHiddenFields, TSurveyWelcomeCard } from "@formbricks/types/v1/surveys"; -import { TTemplate } from "@formbricks/types/v1/templates"; import { createId } from "@paralleldrive/cuid2"; +import { + TSurvey, + TSurveyHiddenFields, + TSurveyQuestionType, + TSurveyWelcomeCard, +} from "@formbricks/types/surveys"; +import { TTemplate } from "@formbricks/types/templates"; + const thankYouCardDefault = { enabled: true, headline: "Thank you!", @@ -15,10 +20,314 @@ const hiddenFieldsDefault: TSurveyHiddenFields = { }; const welcomeCardDefault: TSurveyWelcomeCard = { - enabled: true, + enabled: false, headline: "Welcome!", html: "Thanks for providing your feedback - let's go!", - timeToFinish: false, + timeToFinish: true, + showResponseCount: false, +}; + +export const testTemplate: TTemplate = { + name: "Test template", + description: "Test template consisting of all questions", + preset: { + name: "Test template", + questions: [ + { + id: createId(), + type: TSurveyQuestionType.OpenText, + headline: "This is an open text question", + subheader: "Please enter some text:", + required: true, + inputType: "text", + }, + { + id: createId(), + type: TSurveyQuestionType.OpenText, + headline: "This is an open text question", + subheader: "Please enter some text:", + required: false, + inputType: "text", + }, + { + id: createId(), + type: TSurveyQuestionType.OpenText, + headline: "This is an open text question", + subheader: "Please enter an email", + required: true, + inputType: "email", + }, + { + id: createId(), + type: TSurveyQuestionType.OpenText, + headline: "This is an open text question", + subheader: "Please enter an email", + required: false, + inputType: "email", + }, + { + id: createId(), + type: TSurveyQuestionType.OpenText, + headline: "This is an open text question", + subheader: "Please enter a number", + required: true, + inputType: "number", + }, + { + id: createId(), + type: TSurveyQuestionType.OpenText, + headline: "This is an open text question", + subheader: "Please enter a number", + required: false, + inputType: "number", + }, + { + id: createId(), + type: TSurveyQuestionType.OpenText, + headline: "This is an open text question", + subheader: "Please enter a phone number", + required: true, + inputType: "phone", + }, + { + id: createId(), + type: TSurveyQuestionType.OpenText, + headline: "This is an open text question", + subheader: "Please enter a phone number", + required: false, + inputType: "phone", + }, + { + id: createId(), + type: TSurveyQuestionType.OpenText, + headline: "This is an open text question", + subheader: "Please enter a url", + required: true, + inputType: "url", + }, + { + id: createId(), + type: TSurveyQuestionType.OpenText, + headline: "This is an open text question", + subheader: "Please enter a url", + required: false, + inputType: "url", + }, + { + id: createId(), + type: TSurveyQuestionType.MultipleChoiceSingle, + headline: "This ia a Multiple choice Single question", + subheader: "Please select one of the following", + required: true, + shuffleOption: "none", + choices: [ + { + id: createId(), + label: "Option1", + }, + { + id: createId(), + label: "Option2", + }, + ], + }, + { + id: createId(), + type: TSurveyQuestionType.MultipleChoiceSingle, + headline: "This ia a Multiple choice Single question", + subheader: "Please select one of the following", + required: false, + shuffleOption: "none", + choices: [ + { + id: createId(), + label: "Option 1", + }, + { + id: createId(), + label: "Option 2", + }, + ], + }, + { + id: createId(), + type: TSurveyQuestionType.MultipleChoiceMulti, + headline: "This ia a Multiple choice Multiple question", + subheader: "Please select some from the following", + required: true, + shuffleOption: "none", + choices: [ + { + id: createId(), + label: "Option1", + }, + { + id: createId(), + label: "Option2", + }, + ], + }, + { + id: createId(), + type: TSurveyQuestionType.MultipleChoiceMulti, + headline: "This ia a Multiple choice Multiple question", + subheader: "Please select some from the following", + required: false, + shuffleOption: "none", + choices: [ + { + id: createId(), + label: "Option1", + }, + { + id: createId(), + label: "Option2", + }, + ], + }, + { + id: createId(), + type: TSurveyQuestionType.Rating, + headline: "This is a rating question", + required: true, + lowerLabel: "Low", + upperLabel: "High", + range: 5, + scale: "number", + }, + { + id: createId(), + type: TSurveyQuestionType.Rating, + headline: "This is a rating question", + required: false, + lowerLabel: "Low", + upperLabel: "High", + range: 5, + scale: "number", + }, + { + id: createId(), + type: TSurveyQuestionType.Rating, + headline: "This is a rating question", + required: true, + lowerLabel: "Low", + upperLabel: "High", + range: 5, + scale: "smiley", + }, + { + id: createId(), + type: TSurveyQuestionType.Rating, + headline: "This is a rating question", + required: false, + lowerLabel: "Low", + upperLabel: "High", + range: 5, + scale: "smiley", + }, + { + id: createId(), + type: TSurveyQuestionType.Rating, + headline: "This is a rating question", + required: true, + lowerLabel: "Low", + upperLabel: "High", + range: 5, + scale: "star", + }, + { + id: createId(), + type: TSurveyQuestionType.Rating, + headline: "This is a rating question", + required: false, + lowerLabel: "Low", + upperLabel: "High", + range: 5, + scale: "star", + }, + { + id: createId(), + type: TSurveyQuestionType.CTA, + headline: "This is a CTA question", + html: "This is a test CTA", + buttonLabel: "Click", + buttonUrl: "https://formbricks.com", + buttonExternal: true, + required: true, + dismissButtonLabel: "Maybe later", + }, + { + id: createId(), + type: TSurveyQuestionType.CTA, + headline: "This is a CTA question", + html: "This is a test CTA", + buttonLabel: "Click", + buttonUrl: "https://formbricks.com", + buttonExternal: true, + required: false, + dismissButtonLabel: "Maybe later", + }, + { + id: createId(), + type: TSurveyQuestionType.PictureSelection, + headline: "This is a Picture select", + allowMulti: true, + required: true, + choices: [ + { + id: createId(), + imageUrl: "https://formbricks-cdn.s3.eu-central-1.amazonaws.com/puppy-1-small.jpg", + }, + { + id: createId(), + imageUrl: "https://formbricks-cdn.s3.eu-central-1.amazonaws.com/puppy-2-small.jpg", + }, + ], + }, + { + id: createId(), + type: TSurveyQuestionType.PictureSelection, + headline: "This is a Picture select", + allowMulti: true, + required: false, + choices: [ + { + id: createId(), + imageUrl: "https://formbricks-cdn.s3.eu-central-1.amazonaws.com/puppy-1-small.jpg", + }, + { + id: createId(), + imageUrl: "https://formbricks-cdn.s3.eu-central-1.amazonaws.com/puppy-2-small.jpg", + }, + ], + }, + { + id: createId(), + type: TSurveyQuestionType.Consent, + headline: "This is a Consent question", + required: true, + label: "I agree to the terms and conditions", + dismissButtonLabel: "Skip", + }, + { + id: createId(), + type: TSurveyQuestionType.Consent, + headline: "This is a Consent question", + required: false, + label: "I agree to the terms and conditions", + dismissButtonLabel: "Skip", + }, + ], + thankYouCard: thankYouCardDefault, + welcomeCard: { + enabled: false, + timeToFinish: false, + showResponseCount: false, + }, + hiddenFields: { + enabled: false, + }, + }, }; export const templates: TTemplate[] = [ @@ -34,7 +343,7 @@ export const templates: TTemplate[] = [ { id: createId(), html: '

We would love to understand your user experience better. Sharing your insight helps a lot!

', - type: QuestionType.CTA, + type: TSurveyQuestionType.CTA, logic: [{ condition: "skipped", destination: "end" }], headline: "You are one of our power users! Do you have 5 minutes?", required: false, @@ -44,7 +353,7 @@ export const templates: TTemplate[] = [ }, { id: createId(), - type: QuestionType.MultipleChoiceSingle, + type: TSurveyQuestionType.MultipleChoiceSingle, headline: "How disappointed would you be if you could no longer use {{productName}}?", subheader: "Please select one of the following options:", required: true, @@ -66,7 +375,7 @@ export const templates: TTemplate[] = [ }, { id: createId(), - type: QuestionType.MultipleChoiceSingle, + type: TSurveyQuestionType.MultipleChoiceSingle, headline: "What is your role?", subheader: "Please select one of the following options:", required: true, @@ -96,21 +405,21 @@ export const templates: TTemplate[] = [ }, { id: createId(), - type: QuestionType.OpenText, + type: TSurveyQuestionType.OpenText, headline: "What type of people do you think would most benefit from {{productName}}?", required: true, inputType: "text", }, { id: createId(), - type: QuestionType.OpenText, - headline: "What is the main benefit your receive from {{productName}}?", + type: TSurveyQuestionType.OpenText, + headline: "What is the main benefit you receive from {{productName}}?", required: true, inputType: "text", }, { id: createId(), - type: QuestionType.OpenText, + type: TSurveyQuestionType.OpenText, headline: "How can we improve {{productName}} for you?", subheader: "Please be as specific as possible.", required: true, @@ -132,7 +441,7 @@ export const templates: TTemplate[] = [ questions: [ { id: createId(), - type: QuestionType.MultipleChoiceSingle, + type: TSurveyQuestionType.MultipleChoiceSingle, headline: "What is your role?", subheader: "Please select one of the following options:", required: true, @@ -162,7 +471,7 @@ export const templates: TTemplate[] = [ }, { id: createId(), - type: QuestionType.MultipleChoiceSingle, + type: TSurveyQuestionType.MultipleChoiceSingle, headline: "What's your company size?", subheader: "Please select one of the following options:", required: true, @@ -192,7 +501,7 @@ export const templates: TTemplate[] = [ }, { id: createId(), - type: QuestionType.MultipleChoiceSingle, + type: TSurveyQuestionType.MultipleChoiceSingle, headline: "How did you hear about us first?", subheader: "Please select one of the following options:", required: true, @@ -237,7 +546,7 @@ export const templates: TTemplate[] = [ questions: [ { id: createId(), - type: QuestionType.MultipleChoiceSingle, + type: TSurveyQuestionType.MultipleChoiceSingle, shuffleOption: "none", logic: [ { value: "Difficult to use", condition: "equals", destination: "sxwpskjgzzpmkgfxzi15inif" }, @@ -267,7 +576,7 @@ export const templates: TTemplate[] = [ }, { id: "sxwpskjgzzpmkgfxzi15inif", - type: QuestionType.OpenText, + type: TSurveyQuestionType.OpenText, logic: [{ condition: "submitted", destination: "end" }], headline: "What would have made {{productName}} easier to use?", required: true, @@ -278,7 +587,7 @@ export const templates: TTemplate[] = [ { id: "mao94214zoo6c1at5rpuz7io", html: '

We\'d love to keep you as a customer. Happy to offer a 30% discount for the next year.

', - type: QuestionType.CTA, + type: TSurveyQuestionType.CTA, logic: [{ condition: "clicked", destination: "end" }], headline: "Get 30% off for the next year!", required: true, @@ -289,7 +598,7 @@ export const templates: TTemplate[] = [ }, { id: "l054desub14syoie7n202vq4", - type: QuestionType.OpenText, + type: TSurveyQuestionType.OpenText, logic: [{ condition: "submitted", destination: "end" }], headline: "What features are you missing?", @@ -300,7 +609,7 @@ export const templates: TTemplate[] = [ { id: "hdftsos1odzjllr7flj4m3j9", html: '

We aim to provide the best possible customer service. Please email our CEO and she will personally handle your issue.

', - type: QuestionType.CTA, + type: TSurveyQuestionType.CTA, logic: [{ condition: "clicked", destination: "end" }], headline: "So sorry to hear 😔 Talk to our CEO directly!", required: true, @@ -326,7 +635,7 @@ export const templates: TTemplate[] = [ questions: [ { id: createId(), - type: QuestionType.MultipleChoiceSingle, + type: TSurveyQuestionType.MultipleChoiceSingle, logic: [{ value: "No", condition: "equals", destination: "duz2qp8eftix9wty1l221x1h" }], shuffleOption: "none", choices: [ @@ -339,7 +648,7 @@ export const templates: TTemplate[] = [ }, { id: createId(), - type: QuestionType.OpenText, + type: TSurveyQuestionType.OpenText, logic: [{ condition: "submitted", destination: "yhfew1j3ng6luy7t7qynwj79" }], headline: "Great to hear! Why did you recommend us?", required: true, @@ -348,7 +657,7 @@ export const templates: TTemplate[] = [ }, { id: "duz2qp8eftix9wty1l221x1h", - type: QuestionType.OpenText, + type: TSurveyQuestionType.OpenText, headline: "So sad. Why not?", required: true, placeholder: "Type your answer here...", @@ -356,7 +665,7 @@ export const templates: TTemplate[] = [ }, { id: "yhfew1j3ng6luy7t7qynwj79", - type: QuestionType.MultipleChoiceSingle, + type: TSurveyQuestionType.MultipleChoiceSingle, logic: [{ value: "No", condition: "equals", destination: "end" }], shuffleOption: "none", choices: [ @@ -369,7 +678,7 @@ export const templates: TTemplate[] = [ }, { id: createId(), - type: QuestionType.OpenText, + type: TSurveyQuestionType.OpenText, headline: "What made you discourage them?", required: true, placeholder: "Type your answer here...", @@ -391,7 +700,7 @@ export const templates: TTemplate[] = [ questions: [ { id: createId(), - type: QuestionType.MultipleChoiceSingle, + type: TSurveyQuestionType.MultipleChoiceSingle, shuffleOption: "none", logic: [ { @@ -429,7 +738,7 @@ export const templates: TTemplate[] = [ }, { id: "aew2ymg51mffnt9db7duz9t3", - type: QuestionType.OpenText, + type: TSurveyQuestionType.OpenText, logic: [{ condition: "submitted", destination: "bqiyml1ym74ggx6htwdo7rlu" }], headline: "Sorry to hear. What was the biggest problem using {{productName}}?", required: true, @@ -438,7 +747,7 @@ export const templates: TTemplate[] = [ }, { id: "rnrfydttavtsf2t2nfx1df7m", - type: QuestionType.OpenText, + type: TSurveyQuestionType.OpenText, logic: [{ condition: "submitted", destination: "bqiyml1ym74ggx6htwdo7rlu" }], headline: "What did you expect {{productName}} would do for you?", required: true, @@ -448,7 +757,7 @@ export const templates: TTemplate[] = [ { id: "x760wga1fhtr1i80cpssr7af", html: '

We\'re happy to offer you a 20% discount on a yearly plan.

', - type: QuestionType.CTA, + type: TSurveyQuestionType.CTA, logic: [{ condition: "clicked", destination: "end" }], headline: "Sorry to hear! Get 20% off the first year.", required: true, @@ -459,7 +768,7 @@ export const templates: TTemplate[] = [ }, { id: "rbhww1pix03r6sl4xc511wqg", - type: QuestionType.OpenText, + type: TSurveyQuestionType.OpenText, logic: [{ condition: "submitted", destination: "bqiyml1ym74ggx6htwdo7rlu" }], headline: "Which features are you missing?", required: true, @@ -469,7 +778,7 @@ export const templates: TTemplate[] = [ }, { id: "bqiyml1ym74ggx6htwdo7rlu", - type: QuestionType.OpenText, + type: TSurveyQuestionType.OpenText, logic: [ { condition: "submitted", destination: "end" }, { condition: "skipped", destination: "end" }, @@ -496,20 +805,20 @@ export const templates: TTemplate[] = [ questions: [ { id: createId(), - type: QuestionType.Rating, + type: TSurveyQuestionType.Rating, logic: [{ value: 3, condition: "lessEqual", destination: "tk9wpw2gxgb8fa6pbpp3qq5l" }], range: 5, scale: "star", headline: "How do you like {{productName}}?", required: true, subheader: "", - lowerLabel: "", - upperLabel: "", + lowerLabel: "Not good", + upperLabel: "Very satisfied", }, { id: createId(), html: '

This helps us a lot.

', - type: QuestionType.CTA, + type: TSurveyQuestionType.CTA, logic: [{ condition: "clicked", destination: "end" }], headline: "Happy to hear 🙏 Please write a review for us!", required: true, @@ -519,7 +828,7 @@ export const templates: TTemplate[] = [ }, { id: "tk9wpw2gxgb8fa6pbpp3qq5l", - type: QuestionType.OpenText, + type: TSurveyQuestionType.OpenText, headline: "Sorry to hear! What is ONE thing we can do better?", required: true, subheader: "Help us improve your experience.", @@ -544,7 +853,7 @@ export const templates: TTemplate[] = [ questions: [ { id: createId(), - type: QuestionType.CTA, + type: TSurveyQuestionType.CTA, headline: "Do you have 15 min to talk to us? 🙏", html: "You're one of our power users. We would love to interview you briefly!", buttonLabel: "Book slot", @@ -568,7 +877,7 @@ export const templates: TTemplate[] = [ questions: [ { id: createId(), - type: QuestionType.MultipleChoiceSingle, + type: TSurveyQuestionType.MultipleChoiceSingle, shuffleOption: "none", logic: [ { @@ -601,7 +910,7 @@ export const templates: TTemplate[] = [ }, { id: createId(), - type: QuestionType.OpenText, + type: TSurveyQuestionType.OpenText, logic: [{ condition: "submitted", destination: "end" }], headline: "What made you think {{productName}} wouldn't be useful?", required: true, @@ -611,7 +920,7 @@ export const templates: TTemplate[] = [ }, { id: "r0zvi3vburf4hm7qewimzjux", - type: QuestionType.OpenText, + type: TSurveyQuestionType.OpenText, logic: [{ condition: "submitted", destination: "end" }], headline: "What was difficult about setting up or using {{productName}}?", required: true, @@ -621,7 +930,7 @@ export const templates: TTemplate[] = [ }, { id: "rbwz3y6y9avzqcfj30nu0qj4", - type: QuestionType.OpenText, + type: TSurveyQuestionType.OpenText, logic: [{ condition: "submitted", destination: "end" }], headline: "What features or functionality were missing?", required: true, @@ -631,7 +940,7 @@ export const templates: TTemplate[] = [ }, { id: "gn6298zogd2ipdz7js17qy5i", - type: QuestionType.OpenText, + type: TSurveyQuestionType.OpenText, logic: [{ condition: "submitted", destination: "end" }], headline: "How could we make it easier for you to get started?", required: true, @@ -641,7 +950,7 @@ export const templates: TTemplate[] = [ }, { id: "c0exdyri3erugrv0ezkyseh6", - type: QuestionType.OpenText, + type: TSurveyQuestionType.OpenText, logic: [], headline: "What was it? Please explain:", required: false, @@ -665,7 +974,7 @@ export const templates: TTemplate[] = [ questions: [ { id: createId(), - type: QuestionType.MultipleChoiceSingle, + type: TSurveyQuestionType.MultipleChoiceSingle, shuffleOption: "none", choices: [ { id: createId(), label: "Ease of use" }, @@ -680,7 +989,7 @@ export const templates: TTemplate[] = [ }, { id: createId(), - type: QuestionType.MultipleChoiceSingle, + type: TSurveyQuestionType.MultipleChoiceSingle, shuffleOption: "none", choices: [ { id: createId(), label: "Documentation" }, @@ -694,7 +1003,7 @@ export const templates: TTemplate[] = [ }, { id: createId(), - type: QuestionType.OpenText, + type: TSurveyQuestionType.OpenText, headline: "Would you like to add something?", required: false, subheader: "Feel free to speak your mind, we do too.", @@ -715,7 +1024,7 @@ export const templates: TTemplate[] = [ questions: [ { id: createId(), - type: QuestionType.MultipleChoiceSingle, + type: TSurveyQuestionType.MultipleChoiceSingle, headline: "How disappointed would you be if you could no longer use {{productName}}?", subheader: "Please select one of the following options:", required: true, @@ -737,7 +1046,7 @@ export const templates: TTemplate[] = [ }, { id: createId(), - type: QuestionType.OpenText, + type: TSurveyQuestionType.OpenText, headline: "How can we improve {{productName}} for you?", subheader: "Please be as specific as possible.", required: true, @@ -760,7 +1069,7 @@ export const templates: TTemplate[] = [ questions: [ { id: createId(), - type: QuestionType.MultipleChoiceSingle, + type: TSurveyQuestionType.MultipleChoiceSingle, headline: "How did you hear about us first?", subheader: "Please select one of the following options:", required: true, @@ -805,7 +1114,7 @@ export const templates: TTemplate[] = [ questions: [ { id: createId(), - type: QuestionType.MultipleChoiceSingle, + type: TSurveyQuestionType.MultipleChoiceSingle, headline: "How easy was it to change your plan?", required: true, shuffleOption: "none", @@ -834,7 +1143,7 @@ export const templates: TTemplate[] = [ }, { id: createId(), - type: QuestionType.MultipleChoiceSingle, + type: TSurveyQuestionType.MultipleChoiceSingle, headline: "Is the pricing information easy to understand?", required: true, shuffleOption: "none", @@ -872,7 +1181,7 @@ export const templates: TTemplate[] = [ questions: [ { id: createId(), - type: QuestionType.MultipleChoiceSingle, + type: TSurveyQuestionType.MultipleChoiceSingle, headline: "What's your primary goal for using {{productName}}?", required: true, shuffleOption: "none", @@ -912,7 +1221,7 @@ export const templates: TTemplate[] = [ questions: [ { id: createId(), - type: QuestionType.Rating, + type: TSurveyQuestionType.Rating, range: 5, scale: "number", headline: "How important is [ADD FEATURE] for you?", @@ -922,7 +1231,7 @@ export const templates: TTemplate[] = [ }, { id: createId(), - type: QuestionType.MultipleChoiceSingle, + type: TSurveyQuestionType.MultipleChoiceSingle, shuffleOption: "none", choices: [ { id: createId(), label: "Aspect 1" }, @@ -951,7 +1260,7 @@ export const templates: TTemplate[] = [ questions: [ { id: createId(), - type: QuestionType.Rating, + type: TSurveyQuestionType.Rating, headline: "How important is this feature for you?", required: true, lowerLabel: "Not important", @@ -961,7 +1270,7 @@ export const templates: TTemplate[] = [ }, { id: createId(), - type: QuestionType.MultipleChoiceMulti, + type: TSurveyQuestionType.MultipleChoiceMulti, headline: "What should be definitely include building this?", required: false, shuffleOption: "none", @@ -1001,7 +1310,7 @@ export const templates: TTemplate[] = [ questions: [ { id: createId(), - type: QuestionType.MultipleChoiceSingle, + type: TSurveyQuestionType.MultipleChoiceSingle, shuffleOption: "none", logic: [ { value: "Bug report 🐞", condition: "equals", destination: "dnbiuq4l33l7jypcf2cg6vhh" }, @@ -1017,7 +1326,7 @@ export const templates: TTemplate[] = [ }, { id: "dnbiuq4l33l7jypcf2cg6vhh", - type: QuestionType.OpenText, + type: TSurveyQuestionType.OpenText, logic: [{ condition: "submitted", destination: "a6c76m5oocw6xp9agf3d2tam" }], headline: "What's broken?", required: true, @@ -1027,7 +1336,7 @@ export const templates: TTemplate[] = [ { id: "a6c76m5oocw6xp9agf3d2tam", html: '

We will fix this as soon as possible. Do you want to be notified when we did?

', - type: QuestionType.CTA, + type: TSurveyQuestionType.CTA, logic: [ { condition: "clicked", destination: "end" }, { condition: "skipped", destination: "end" }, @@ -1040,7 +1349,7 @@ export const templates: TTemplate[] = [ }, { id: "en9nuuevbf7g9oa9rzcs1l50", - type: QuestionType.OpenText, + type: TSurveyQuestionType.OpenText, headline: "Lovely, tell us more!", required: true, subheader: "What problem do you want us to solve?", @@ -1065,7 +1374,7 @@ export const templates: TTemplate[] = [ questions: [ { id: "s6ss6znzxdwjod1hv16fow4w", - type: QuestionType.Rating, + type: TSurveyQuestionType.Rating, logic: [{ value: 4, condition: "greaterEqual", destination: "ef0qo3l8iisd517ikp078u1p" }], range: 5, scale: "number", @@ -1077,7 +1386,7 @@ export const templates: TTemplate[] = [ }, { id: "mko13ptjj6tpi5u2pl7a5drz", - type: QuestionType.OpenText, + type: TSurveyQuestionType.OpenText, headline: "Why was it hard?", required: false, placeholder: "Type your answer here...", @@ -1085,7 +1394,7 @@ export const templates: TTemplate[] = [ }, { id: "ef0qo3l8iisd517ikp078u1p", - type: QuestionType.OpenText, + type: TSurveyQuestionType.OpenText, headline: "What other tools would you like to use with {{productName}}?", required: false, subheader: "We keep building integrations, yours can be next:", @@ -1108,7 +1417,7 @@ export const templates: TTemplate[] = [ questions: [ { id: createId(), - type: QuestionType.MultipleChoiceSingle, + type: TSurveyQuestionType.MultipleChoiceSingle, headline: "Which other tools are you using?", required: true, shuffleOption: "none", @@ -1149,7 +1458,7 @@ export const templates: TTemplate[] = [ questions: [ { id: createId(), - type: QuestionType.MultipleChoiceSingle, + type: TSurveyQuestionType.MultipleChoiceSingle, headline: "Was this page helpful?", required: true, shuffleOption: "none", @@ -1166,14 +1475,14 @@ export const templates: TTemplate[] = [ }, { id: createId(), - type: QuestionType.OpenText, + type: TSurveyQuestionType.OpenText, headline: "Please elaborate:", required: false, inputType: "text", }, { id: createId(), - type: QuestionType.OpenText, + type: TSurveyQuestionType.OpenText, headline: "Page URL", required: false, inputType: "text", @@ -1195,7 +1504,7 @@ export const templates: TTemplate[] = [ questions: [ { id: createId(), - type: QuestionType.NPS, + type: TSurveyQuestionType.NPS, headline: "How likely are you to recommend {{productName}} to a friend or colleague?", required: false, lowerLabel: "Not likely", @@ -1203,7 +1512,7 @@ export const templates: TTemplate[] = [ }, { id: createId(), - type: QuestionType.OpenText, + type: TSurveyQuestionType.OpenText, headline: "What made you give that rating?", required: false, inputType: "text", @@ -1225,7 +1534,7 @@ export const templates: TTemplate[] = [ questions: [ { id: createId(), - type: QuestionType.Rating, + type: TSurveyQuestionType.Rating, logic: [{ value: 3, condition: "lessEqual", destination: "vyo4mkw4ln95ts4ya7qp2tth" }], range: 5, scale: "smiley", @@ -1237,7 +1546,7 @@ export const templates: TTemplate[] = [ }, { id: createId(), - type: QuestionType.OpenText, + type: TSurveyQuestionType.OpenText, logic: [{ condition: "submitted", destination: "end" }], headline: "Lovely! Is there anything we can do to improve your experience?", required: false, @@ -1246,7 +1555,7 @@ export const templates: TTemplate[] = [ }, { id: "vyo4mkw4ln95ts4ya7qp2tth", - type: QuestionType.OpenText, + type: TSurveyQuestionType.OpenText, headline: "Ugh, sorry! Is there anything we can do to improve your experience?", required: false, placeholder: "Type your answer here...", @@ -1269,7 +1578,7 @@ export const templates: TTemplate[] = [ questions: [ { id: createId(), - type: QuestionType.MultipleChoiceSingle, + type: TSurveyQuestionType.MultipleChoiceSingle, headline: "How many hours does your team save per week by using {{productName}}?", required: true, shuffleOption: "none", @@ -1310,7 +1619,7 @@ export const templates: TTemplate[] = [ questions: [ { id: createId(), - type: QuestionType.MultipleChoiceSingle, + type: TSurveyQuestionType.MultipleChoiceSingle, logic: [], shuffleOption: "none", choices: [ @@ -1325,7 +1634,7 @@ export const templates: TTemplate[] = [ }, { id: createId(), - type: QuestionType.MultipleChoiceSingle, + type: TSurveyQuestionType.MultipleChoiceSingle, logic: [], shuffleOption: "none", choices: [ @@ -1339,7 +1648,7 @@ export const templates: TTemplate[] = [ }, { id: createId(), - type: QuestionType.OpenText, + type: TSurveyQuestionType.OpenText, headline: "How else could we improve you experience with {{productName}}?", required: true, placeholder: "Type your answer here...", @@ -1362,7 +1671,7 @@ export const templates: TTemplate[] = [ questions: [ { id: createId(), - type: QuestionType.Rating, + type: TSurveyQuestionType.Rating, headline: "How easy was it to achieve ... ?", required: true, lowerLabel: "Not easy", @@ -1372,7 +1681,7 @@ export const templates: TTemplate[] = [ }, { id: createId(), - type: QuestionType.OpenText, + type: TSurveyQuestionType.OpenText, headline: "What is one thing we could do better?", required: false, inputType: "text", @@ -1394,7 +1703,7 @@ export const templates: TTemplate[] = [ questions: [ { id: createId(), - type: QuestionType.MultipleChoiceSingle, + type: TSurveyQuestionType.MultipleChoiceSingle, headline: "Do you have all the info you need to give {{productName}} a try?", required: true, shuffleOption: "none", @@ -1415,14 +1724,14 @@ export const templates: TTemplate[] = [ }, { id: createId(), - type: QuestionType.OpenText, + type: TSurveyQuestionType.OpenText, headline: "What’s missing or unclear to you about {{productName}}?", required: false, inputType: "text", }, { id: createId(), - type: QuestionType.CTA, + type: TSurveyQuestionType.CTA, headline: "Thanks for your answer! Get 25% off your first 6 months:", required: false, buttonLabel: "Get discount", @@ -1446,7 +1755,7 @@ export const templates: TTemplate[] = [ questions: [ { id: createId(), - type: QuestionType.Rating, + type: TSurveyQuestionType.Rating, range: 5, scale: "number", headline: "{{productName}} makes it easy for me to [ADD GOAL]", @@ -1457,7 +1766,7 @@ export const templates: TTemplate[] = [ }, { id: createId(), - type: QuestionType.OpenText, + type: TSurveyQuestionType.OpenText, headline: "Thanks! How could we make it easier for you to [ADD GOAL]?", required: true, placeholder: "Type your answer here...", @@ -1481,7 +1790,7 @@ export const templates: TTemplate[] = [ questions: [ { id: createId(), - type: QuestionType.Rating, + type: TSurveyQuestionType.Rating, logic: [{ value: 4, condition: "greaterEqual", destination: "lpof3d9t9hmnqvyjlpksmxd7" }], range: 5, scale: "number", @@ -1493,7 +1802,7 @@ export const templates: TTemplate[] = [ }, { id: createId(), - type: QuestionType.OpenText, + type: TSurveyQuestionType.OpenText, logic: [{ condition: "submitted", destination: "end" }], headline: "Sorry about that! What would have made it easier for you?", required: true, @@ -1502,7 +1811,7 @@ export const templates: TTemplate[] = [ }, { id: "lpof3d9t9hmnqvyjlpksmxd7", - type: QuestionType.OpenText, + type: TSurveyQuestionType.OpenText, headline: "Lovely! Is there anything we can do to improve your experience?", required: true, placeholder: "Type your answer here...", @@ -1525,7 +1834,7 @@ export const templates: TTemplate[] = [ questions: [ { id: createId(), - type: QuestionType.Rating, + type: TSurveyQuestionType.Rating, logic: [{ value: 4, condition: "greaterEqual", destination: "adcs3d9t9hmnqvyjlpksmxd7" }], range: 5, scale: "number", @@ -1537,7 +1846,7 @@ export const templates: TTemplate[] = [ }, { id: createId(), - type: QuestionType.OpenText, + type: TSurveyQuestionType.OpenText, logic: [{ condition: "submitted", destination: "end" }], headline: "Ugh! What makes the results irrelevant for you?", required: true, @@ -1546,7 +1855,7 @@ export const templates: TTemplate[] = [ }, { id: "adcs3d9t9hmnqvyjlpksmxd7", - type: QuestionType.OpenText, + type: TSurveyQuestionType.OpenText, headline: "Lovely! Is there anything we can do to improve your experience?", required: true, placeholder: "Type your answer here...", @@ -1569,7 +1878,7 @@ export const templates: TTemplate[] = [ questions: [ { id: createId(), - type: QuestionType.Rating, + type: TSurveyQuestionType.Rating, logic: [{ value: 4, condition: "greaterEqual", destination: "adcs3d9t9hmnqvyjlpkswi38" }], range: 5, scale: "number", @@ -1581,7 +1890,7 @@ export const templates: TTemplate[] = [ }, { id: createId(), - type: QuestionType.OpenText, + type: TSurveyQuestionType.OpenText, logic: [{ condition: "submitted", destination: "end" }], headline: "Hmpft! What were you hoping for?", required: true, @@ -1590,7 +1899,7 @@ export const templates: TTemplate[] = [ }, { id: "adcs3d9t9hmnqvyjlpkswi38", - type: QuestionType.OpenText, + type: TSurveyQuestionType.OpenText, headline: "Lovely! Is there anything else you would like us to cover?", required: true, placeholder: "Topics, trends, tutorials...", @@ -1613,7 +1922,7 @@ export const templates: TTemplate[] = [ questions: [ { id: createId(), - type: QuestionType.MultipleChoiceSingle, + type: TSurveyQuestionType.MultipleChoiceSingle, shuffleOption: "none", logic: [ { value: "Working on it, boss", condition: "equals", destination: "nq88udm0jjtylr16ax87xlyc" }, @@ -1630,7 +1939,7 @@ export const templates: TTemplate[] = [ }, { id: "rjeac33gd13h3nnbrbid1fb2", - type: QuestionType.Rating, + type: TSurveyQuestionType.Rating, logic: [{ value: 4, condition: "greaterEqual", destination: "nq88udm0jjtylr16ax87xlyc" }], range: 5, scale: "number", @@ -1641,7 +1950,7 @@ export const templates: TTemplate[] = [ }, { id: "s0999bhpaz8vgf7ps264piek", - type: QuestionType.OpenText, + type: TSurveyQuestionType.OpenText, logic: [ { condition: "submitted", destination: "end" }, { condition: "skipped", destination: "end" }, @@ -1653,7 +1962,7 @@ export const templates: TTemplate[] = [ }, { id: "nq88udm0jjtylr16ax87xlyc", - type: QuestionType.OpenText, + type: TSurveyQuestionType.OpenText, logic: [ { condition: "skipped", destination: "end" }, { condition: "submitted", destination: "end" }, @@ -1665,7 +1974,7 @@ export const templates: TTemplate[] = [ }, { id: "u83zhr66knyfozccoqojx7bc", - type: QuestionType.OpenText, + type: TSurveyQuestionType.OpenText, headline: "What stopped you?", required: true, buttonLabel: "Send", @@ -1690,7 +1999,7 @@ export const templates: TTemplate[] = [ { id: createId(), html: '

You seem to be considering signing up. Answer four questions and get 10% on any plan.

', - type: QuestionType.CTA, + type: TSurveyQuestionType.CTA, logic: [{ condition: "skipped", destination: "end" }], headline: "Answer this short survey, get 10% off!", required: false, @@ -1700,7 +2009,7 @@ export const templates: TTemplate[] = [ }, { id: createId(), - type: QuestionType.Rating, + type: TSurveyQuestionType.Rating, logic: [{ value: "5", condition: "equals", destination: "end" }], range: 5, scale: "number", @@ -1712,7 +2021,7 @@ export const templates: TTemplate[] = [ }, { id: createId(), - type: QuestionType.MultipleChoiceSingle, + type: TSurveyQuestionType.MultipleChoiceSingle, shuffleOption: "none", logic: [ { @@ -1742,7 +2051,7 @@ export const templates: TTemplate[] = [ }, { id: "atiw0j1oykb77zr0b7q4tixu", - type: QuestionType.OpenText, + type: TSurveyQuestionType.OpenText, logic: [{ condition: "submitted", destination: "k3q0vt1ko0bzbsq076p7lnys" }], headline: "What do you need but {{productName}} does not offer?", required: true, @@ -1751,7 +2060,7 @@ export const templates: TTemplate[] = [ }, { id: "j7jkpolm5xl7u0zt3g0e4z7d", - type: QuestionType.OpenText, + type: TSurveyQuestionType.OpenText, logic: [{ condition: "submitted", destination: "k3q0vt1ko0bzbsq076p7lnys" }], headline: "What options are you looking at?", required: true, @@ -1760,7 +2069,7 @@ export const templates: TTemplate[] = [ }, { id: "t5gvag2d7kq311szz5iyiy79", - type: QuestionType.OpenText, + type: TSurveyQuestionType.OpenText, logic: [{ condition: "submitted", destination: "k3q0vt1ko0bzbsq076p7lnys" }], headline: "What seems complicated to you?", required: true, @@ -1769,7 +2078,7 @@ export const templates: TTemplate[] = [ }, { id: "or0yhhrof753sq9ug4mdavgz", - type: QuestionType.OpenText, + type: TSurveyQuestionType.OpenText, logic: [{ condition: "submitted", destination: "k3q0vt1ko0bzbsq076p7lnys" }], headline: "What are you concerned about regarding pricing?", required: true, @@ -1778,7 +2087,7 @@ export const templates: TTemplate[] = [ }, { id: "v0pq1qcnm6ohiry5ywcd91qq", - type: QuestionType.OpenText, + type: TSurveyQuestionType.OpenText, headline: "Please explain:", required: true, placeholder: "Type your answer here...", @@ -1787,7 +2096,7 @@ export const templates: TTemplate[] = [ { id: "k3q0vt1ko0bzbsq076p7lnys", html: '

Thanks a lot for taking the time to share feedback 🙏

', - type: QuestionType.CTA, + type: TSurveyQuestionType.CTA, headline: "Thanks! Here is your code: SIGNUPNOW10", required: false, buttonUrl: "https://app.formbricks.com/auth/signup", @@ -1812,7 +2121,7 @@ export const templates: TTemplate[] = [ questions: [ { id: createId(), - type: QuestionType.Rating, + type: TSurveyQuestionType.Rating, range: 5, scale: "number", headline: "How satisfied are you with the features and functionality of {{productName}}?", @@ -1823,7 +2132,7 @@ export const templates: TTemplate[] = [ }, { id: createId(), - type: QuestionType.OpenText, + type: TSurveyQuestionType.OpenText, headline: "What's ONE change we could make to improve your {{productName}} experience most?", required: true, subheader: "", @@ -1847,7 +2156,7 @@ export const templates: TTemplate[] = [ questions: [ { id: createId(), - type: QuestionType.Rating, + type: TSurveyQuestionType.Rating, logic: [ { value: "2", condition: "lessEqual", destination: "y19mwcmstlc7pi7s4izxk1ll" }, { value: "3", condition: "equals", destination: "zm1hs8qkeuidh3qm0hx8pnw7" }, @@ -1864,7 +2173,7 @@ export const templates: TTemplate[] = [ }, { id: "y19mwcmstlc7pi7s4izxk1ll", - type: QuestionType.OpenText, + type: TSurveyQuestionType.OpenText, logic: [ { condition: "submitted", destination: "end" }, { condition: "skipped", destination: "end" }, @@ -1876,7 +2185,7 @@ export const templates: TTemplate[] = [ }, { id: "zm1hs8qkeuidh3qm0hx8pnw7", - type: QuestionType.OpenText, + type: TSurveyQuestionType.OpenText, headline: "What, if anything, is holding you back from making a purchase today?", required: true, placeholder: "Type your answer here...", @@ -1899,7 +2208,7 @@ export const templates: TTemplate[] = [ questions: [ { id: createId(), - type: QuestionType.Rating, + type: TSurveyQuestionType.Rating, logic: [ { value: "5", condition: "equals", destination: "l2q1chqssong8n0xwaagyl8g" }, { value: "5", condition: "lessThan", destination: "k3s6gm5ivkc5crpycdbpzkpa" }, @@ -1914,7 +2223,7 @@ export const templates: TTemplate[] = [ }, { id: "k3s6gm5ivkc5crpycdbpzkpa", - type: QuestionType.OpenText, + type: TSurveyQuestionType.OpenText, logic: [ { condition: "submitted", destination: "end" }, { condition: "skipped", destination: "end" }, @@ -1927,7 +2236,7 @@ export const templates: TTemplate[] = [ { id: "l2q1chqssong8n0xwaagyl8g", html: '

Who thinks like you? You\'d do us a huge favor if you\'d share this weeks episode with your brain friend!

', - type: QuestionType.CTA, + type: TSurveyQuestionType.CTA, headline: "Thanks! ❤️ Spread the love with ONE friend.", required: false, buttonUrl: "https://formbricks.com", @@ -1953,7 +2262,7 @@ export const templates: TTemplate[] = [ { id: createId(), html: '

We respect your time and kept it short 🤸

', - type: QuestionType.CTA, + type: TSurveyQuestionType.CTA, headline: "We love how you use {{productName}}! We'd love to pick your brain on a feature idea. Got a minute?", required: true, @@ -1963,7 +2272,7 @@ export const templates: TTemplate[] = [ }, { id: createId(), - type: QuestionType.Rating, + type: TSurveyQuestionType.Rating, logic: [ { value: "3", condition: "lessEqual", destination: "ndacjg9lqf5jcpq9w8ote666" }, { value: "4", condition: "greaterEqual", destination: "jmzgbo73cfjswlvhoynn7o0q" }, @@ -1978,7 +2287,7 @@ export const templates: TTemplate[] = [ }, { id: "ndacjg9lqf5jcpq9w8ote666", - type: QuestionType.OpenText, + type: TSurveyQuestionType.OpenText, headline: "What's most difficult for you when it comes to [PROBLEM AREA]?", required: true, subheader: "", @@ -1988,7 +2297,7 @@ export const templates: TTemplate[] = [ { id: "jmzgbo73cfjswlvhoynn7o0q", html: '


Read the text below, then answer 2 questions:


Insert concept brief here. Add neccessary details but keep it concise and easy to understand.

', - type: QuestionType.CTA, + type: TSurveyQuestionType.CTA, headline: "We're working on an idea to help with [PROBLEM AREA].", required: true, buttonLabel: "Next", @@ -1997,7 +2306,7 @@ export const templates: TTemplate[] = [ }, { id: createId(), - type: QuestionType.Rating, + type: TSurveyQuestionType.Rating, logic: [ { value: "3", condition: "lessEqual", destination: "mmiuun3z4e7gk4ufuwh8lq8q" }, { value: "4", condition: "greaterEqual", destination: "gvzevzw4hkqd6dmlkcly6kd1" }, @@ -2012,7 +2321,7 @@ export const templates: TTemplate[] = [ }, { id: "mmiuun3z4e7gk4ufuwh8lq8q", - type: QuestionType.OpenText, + type: TSurveyQuestionType.OpenText, logic: [{ condition: "submitted", destination: "bqmnpyku9etsgbtb322luzb2" }], headline: "Got it. Why wouldn't this feature be valuable to you?", required: true, @@ -2021,7 +2330,7 @@ export const templates: TTemplate[] = [ }, { id: "gvzevzw4hkqd6dmlkcly6kd1", - type: QuestionType.OpenText, + type: TSurveyQuestionType.OpenText, headline: "Got it. What would be most valuable to you in this feature?", required: true, placeholder: "Type your answer here...", @@ -2029,7 +2338,7 @@ export const templates: TTemplate[] = [ }, { id: "bqmnpyku9etsgbtb322luzb2", - type: QuestionType.OpenText, + type: TSurveyQuestionType.OpenText, headline: "Anything else we should keep in mind?", required: false, placeholder: "Type your answer here...", @@ -2052,7 +2361,7 @@ export const templates: TTemplate[] = [ questions: [ { id: "aq9dafe9nxe0kpm67b1os2z9", - type: QuestionType.MultipleChoiceSingle, + type: TSurveyQuestionType.MultipleChoiceSingle, shuffleOption: "none", logic: [ { value: "Difficult to use", condition: "equals", destination: "r0zvi3vburf4hm7qewimzjux" }, @@ -2086,7 +2395,7 @@ export const templates: TTemplate[] = [ }, { id: "r0zvi3vburf4hm7qewimzjux", - type: QuestionType.OpenText, + type: TSurveyQuestionType.OpenText, logic: [{ condition: "submitted", destination: "end" }], headline: "What's difficult about using {{productName}}?", required: true, @@ -2096,7 +2405,7 @@ export const templates: TTemplate[] = [ }, { id: "g92s5wetp51ps6afmc6y7609", - type: QuestionType.OpenText, + type: TSurveyQuestionType.OpenText, logic: [{ condition: "submitted", destination: "end" }], headline: "Got it. Which alternative are you using instead?", required: true, @@ -2106,7 +2415,7 @@ export const templates: TTemplate[] = [ }, { id: "gn6298zogd2ipdz7js17qy5i", - type: QuestionType.OpenText, + type: TSurveyQuestionType.OpenText, logic: [{ condition: "submitted", destination: "end" }], headline: "Got it. How could we make it easier for you to get started?", required: true, @@ -2116,7 +2425,7 @@ export const templates: TTemplate[] = [ }, { id: "rbwz3y6y9avzqcfj30nu0qj4", - type: QuestionType.OpenText, + type: TSurveyQuestionType.OpenText, logic: [{ condition: "submitted", destination: "end" }], headline: "Got it. What features or functionality were missing?", required: true, @@ -2126,7 +2435,7 @@ export const templates: TTemplate[] = [ }, { id: "c0exdyri3erugrv0ezkyseh6", - type: QuestionType.OpenText, + type: TSurveyQuestionType.OpenText, logic: [], headline: "Please add more details:", required: false, @@ -2175,8 +2484,8 @@ export const customSurvey: TTemplate = { questions: [ { id: createId(), - type: QuestionType.OpenText, - headline: "Custom Survey", + type: TSurveyQuestionType.OpenText, + headline: "What would you like to know?", subheader: "This is an example survey.", placeholder: "Type your answer here...", required: true, @@ -2218,4 +2527,6 @@ export const minimalSurvey: TSurvey = { }, productOverwrites: null, singleUse: null, + styling: null, + resultShareKey: null, }; diff --git a/apps/web/app/(app)/layout.tsx b/apps/web/app/(app)/layout.tsx index 3f7003dc22..81206ccef0 100644 --- a/apps/web/app/(app)/layout.tsx +++ b/apps/web/app/(app)/layout.tsx @@ -1,17 +1,19 @@ import FormbricksClient from "@/app/(app)/components/FormbricksClient"; -import { PHProvider, PostHogPageview } from "@formbricks/ui/PostHogClient"; -import { authOptions } from "@formbricks/lib/authOptions"; import { getServerSession } from "next-auth"; -import { redirect } from "next/navigation"; +// import { redirect } from "next/navigation"; import { Suspense } from "react"; -import PosthogIdentify from "./components/PosthogIdentify"; + +import { authOptions } from "@formbricks/lib/authOptions"; import { NoMobileOverlay } from "@formbricks/ui/NoMobileOverlay"; +import { PHProvider, PostHogPageview } from "@formbricks/ui/PostHogClient"; + +import PosthogIdentify from "./components/PosthogIdentify"; export default async function AppLayout({ children }) { const session = await getServerSession(authOptions); - if (!session) { - return redirect(`/auth/login`); - } + // if (!session) { + // return redirect(`/auth/login`); + // } return ( <> @@ -21,8 +23,13 @@ export default async function AppLayout({ children }) { <> - - + {session ? ( + <> + + + + ) : null} + {children} diff --git a/apps/web/app/(app)/onboarding/actions.ts b/apps/web/app/(app)/onboarding/actions.ts index 1f7e1d163c..5e53d6793c 100644 --- a/apps/web/app/(app)/onboarding/actions.ts +++ b/apps/web/app/(app)/onboarding/actions.ts @@ -1,19 +1,20 @@ "use server"; -import { authOptions } from "@formbricks/lib/authOptions"; -import { updateProduct } from "@formbricks/lib/product/service"; -import { updateProfile } from "@formbricks/lib/profile/service"; -import { TProductUpdateInput } from "@formbricks/types/v1/product"; -import { TProfileUpdateInput } from "@formbricks/types/v1/profile"; import { getServerSession } from "next-auth"; -import { AuthorizationError } from "@formbricks/types/v1/errors"; -import { canUserAccessProduct } from "@formbricks/lib/product/auth"; -export async function updateProfileAction(updatedProfile: Partial) { +import { authOptions } from "@formbricks/lib/authOptions"; +import { canUserAccessProduct, verifyUserRoleAccess } from "@formbricks/lib/product/auth"; +import { getProduct, updateProduct } from "@formbricks/lib/product/service"; +import { updateUser } from "@formbricks/lib/user/service"; +import { AuthorizationError } from "@formbricks/types/errors"; +import { TProductUpdateInput } from "@formbricks/types/product"; +import { TUserUpdateInput } from "@formbricks/types/user"; + +export async function updateUserAction(updatedUser: TUserUpdateInput) { const session = await getServerSession(authOptions); if (!session) throw new AuthorizationError("Not authorized"); - return await updateProfile(session.user.id, updatedProfile); + return await updateUser(session.user.id, updatedUser); } export async function updateProductAction(productId: string, updatedProduct: Partial) { @@ -23,5 +24,10 @@ export async function updateProductAction(productId: string, updatedProduct: Par const isAuthorized = await canUserAccessProduct(session.user.id, productId); if (!isAuthorized) throw new AuthorizationError("Not authorized"); + const product = await getProduct(productId); + + const { hasCreateOrUpdateAccess } = await verifyUserRoleAccess(product!.teamId, session.user.id); + if (!hasCreateOrUpdateAccess) throw new AuthorizationError("Not authorized"); + return await updateProduct(productId, updatedProduct); } diff --git a/apps/web/app/(app)/onboarding/components/Greeting.tsx b/apps/web/app/(app)/onboarding/components/Greeting.tsx index 197bde421f..3c34029472 100644 --- a/apps/web/app/(app)/onboarding/components/Greeting.tsx +++ b/apps/web/app/(app)/onboarding/components/Greeting.tsx @@ -1,8 +1,10 @@ "use client"; -import { Button } from "@formbricks/ui/Button"; import type { Session } from "next-auth"; import Link from "next/link"; +import { useEffect, useRef } from "react"; + +import { Button } from "@formbricks/ui/Button"; type Greeting = { next: () => void; @@ -13,6 +15,27 @@ type Greeting = { const Greeting: React.FC = ({ next, skip, name, session }) => { const legacyUser = !session ? false : new Date(session?.user?.createdAt) < new Date("2023-05-03T00:00:00"); // if user is created before onboarding deployment + const buttonRef = useRef(null); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Enter") { + event.preventDefault(); + next(); + } + }; + const button = buttonRef.current; + if (button) { + button.focus(); + button.addEventListener("keydown", handleKeyDown); + } + + return () => { + if (button) { + button.removeEventListener("keydown", handleKeyDown); + } + }; + }, []); return (
@@ -30,7 +53,7 @@ const Greeting: React.FC = ({ next, skip, name, session }) => { -
diff --git a/apps/web/app/(app)/onboarding/components/Objective.tsx b/apps/web/app/(app)/onboarding/components/Objective.tsx index 6cd40a78ae..073a03d525 100644 --- a/apps/web/app/(app)/onboarding/components/Objective.tsx +++ b/apps/web/app/(app)/onboarding/components/Objective.tsx @@ -1,28 +1,30 @@ "use client"; -import { updateProfileAction } from "@/app/(app)/onboarding/actions"; -import { env } from "@/env.mjs"; +import { updateUserAction } from "@/app/(app)/onboarding/actions"; import { formbricksEnabled, updateResponse } from "@/app/lib/formbricks"; -import { cn } from "@formbricks/lib/cn"; -import { Objective } from "@formbricks/types/templates"; -import { TProfile } from "@formbricks/types/v1/profile"; -import { Button } from "@formbricks/ui/Button"; -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { toast } from "react-hot-toast"; +import { cn } from "@formbricks/lib/cn"; +import { env } from "@formbricks/lib/env.mjs"; +import { TUser, TUserObjective } from "@formbricks/types/user"; +import { Button } from "@formbricks/ui/Button"; + +import { handleTabNavigation } from "../utils"; + type ObjectiveProps = { next: () => void; skip: () => void; formbricksResponseId?: string; - profile: TProfile; + user: TUser; }; type ObjectiveChoice = { label: string; - id: Objective; + id: TUserObjective; }; -const Objective: React.FC = ({ next, skip, formbricksResponseId, profile }) => { +const Objective: React.FC = ({ next, skip, formbricksResponseId, user }) => { const objectives: Array = [ { label: "Increase conversion", id: "increase_conversion" }, { label: "Improve user retention", id: "improve_user_retention" }, @@ -35,18 +37,26 @@ const Objective: React.FC = ({ next, skip, formbricksResponseId, const [selectedChoice, setSelectedChoice] = useState(null); const [isProfileUpdating, setIsProfileUpdating] = useState(false); + const fieldsetRef = useRef(null); + + useEffect(() => { + const onKeyDown = handleTabNavigation(fieldsetRef, setSelectedChoice); + window.addEventListener("keydown", onKeyDown); + return () => { + window.removeEventListener("keydown", onKeyDown); + }; + }, [fieldsetRef, setSelectedChoice]); + const handleNextClick = async () => { if (selectedChoice) { const selectedObjective = objectives.find((objective) => objective.label === selectedChoice); if (selectedObjective) { try { setIsProfileUpdating(true); - const updatedProfile = { - ...profile, + await updateUserAction({ objective: selectedObjective.id, - name: profile.name ?? undefined, - }; - await updateProfileAction(updatedProfile); + name: user.name ?? undefined, + }); setIsProfileUpdating(false); } catch (e) { setIsProfileUpdating(false); @@ -73,14 +83,14 @@ const Objective: React.FC = ({ next, skip, formbricksResponseId, return (
-
+ ); +} diff --git a/apps/web/app/(app)/share/[sharingKey]/(analysis)/responses/components/ResponsePage.tsx b/apps/web/app/(app)/share/[sharingKey]/(analysis)/responses/components/ResponsePage.tsx new file mode 100644 index 0000000000..42f809cdb3 --- /dev/null +++ b/apps/web/app/(app)/share/[sharingKey]/(analysis)/responses/components/ResponsePage.tsx @@ -0,0 +1,82 @@ +"use client"; + +import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext"; +import CustomFilter from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter"; +import SurveyResultsTabs from "@/app/(app)/share/[sharingKey]/(analysis)/components/SurveyResultsTabs"; +import ResponseTimeline from "@/app/(app)/share/[sharingKey]/(analysis)/responses/components/ResponseTimeline"; +import SummaryHeader from "@/app/(app)/share/[sharingKey]/components/SummaryHeader"; +import { getFilterResponses } from "@/app/lib/surveys/surveys"; +import { useSearchParams } from "next/navigation"; +import { useEffect, useMemo } from "react"; + +import { TEnvironment } from "@formbricks/types/environment"; +import { TProduct } from "@formbricks/types/product"; +import { TResponse } from "@formbricks/types/responses"; +import { TSurvey } from "@formbricks/types/surveys"; +import { TTag } from "@formbricks/types/tags"; +import ContentWrapper from "@formbricks/ui/ContentWrapper"; + +interface ResponsePageProps { + environment: TEnvironment; + survey: TSurvey; + surveyId: string; + responses: TResponse[]; + webAppUrl: string; + product: TProduct; + sharingKey: string; + environmentTags: TTag[]; + responsesPerPage: number; +} + +const ResponsePage = ({ + environment, + survey, + surveyId, + responses, + product, + sharingKey, + environmentTags, + responsesPerPage, +}: ResponsePageProps) => { + const { selectedFilter, dateRange, resetState } = useResponseFilter(); + + const searchParams = useSearchParams(); + + useEffect(() => { + if (!searchParams?.get("referer")) { + resetState(); + } + }, [searchParams]); + + // get the filtered array when the selected filter value changes + const filterResponses: TResponse[] = useMemo(() => { + return getFilterResponses(responses, selectedFilter, survey, dateRange); + }, [selectedFilter, responses, survey, dateRange]); + return ( + + + + + + + ); +}; + +export default ResponsePage; diff --git a/apps/web/app/(app)/share/[sharingKey]/(analysis)/responses/components/ResponseTimeline.tsx b/apps/web/app/(app)/share/[sharingKey]/(analysis)/responses/components/ResponseTimeline.tsx new file mode 100644 index 0000000000..1ee9bc759f --- /dev/null +++ b/apps/web/app/(app)/share/[sharingKey]/(analysis)/responses/components/ResponseTimeline.tsx @@ -0,0 +1,91 @@ +"use client"; + +import EmptyInAppSurveys from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/EmptyInAppSurveys"; +import { useEffect, useRef, useState } from "react"; + +import { TEnvironment } from "@formbricks/types/environment"; +import { TResponse } from "@formbricks/types/responses"; +import { TSurvey } from "@formbricks/types/surveys"; +import { TTag } from "@formbricks/types/tags"; +import EmptySpaceFiller from "@formbricks/ui/EmptySpaceFiller"; +import SingleResponseCard from "@formbricks/ui/SingleResponseCard"; + +interface ResponseTimelineProps { + environment: TEnvironment; + surveyId: string; + responses: TResponse[]; + survey: TSurvey; + environmentTags: TTag[]; + responsesPerPage: number; +} + +export default function ResponseTimeline({ + environment, + responses, + survey, + environmentTags, + responsesPerPage, +}: ResponseTimelineProps) { + const [displayedResponses, setDisplayedResponses] = useState([]); + const loadingRef = useRef(null); + + useEffect(() => { + setDisplayedResponses(responses.slice(0, responsesPerPage)); + }, [responses]); + + useEffect(() => { + const observer = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting) { + setDisplayedResponses((prevResponses) => [ + ...prevResponses, + ...responses.slice(prevResponses.length, prevResponses.length + responsesPerPage), + ]); + } + }, + { threshold: 0.8 } + ); + + if (loadingRef.current) { + observer.observe(loadingRef.current); + } + + return () => { + if (loadingRef.current) { + observer.unobserve(loadingRef.current); + } + }; + }, [responses]); + + return ( +
+ {survey.type === "web" && displayedResponses.length === 0 && !environment.widgetSetupCompleted ? ( + + ) : displayedResponses.length === 0 ? ( + + ) : ( +
+ {displayedResponses.map((response) => { + return ( +
+ +
+ ); + })} +
+
+ )} +
+ ); +} diff --git a/apps/web/app/(app)/share/[sharingKey]/(analysis)/responses/page.tsx b/apps/web/app/(app)/share/[sharingKey]/(analysis)/responses/page.tsx new file mode 100644 index 0000000000..2453bbc67f --- /dev/null +++ b/apps/web/app/(app)/share/[sharingKey]/(analysis)/responses/page.tsx @@ -0,0 +1,57 @@ +import { getAnalysisData } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/data"; +import ResponsePage from "@/app/(app)/share/[sharingKey]/(analysis)/responses/components/ResponsePage"; +import { getResultShareUrlSurveyAction } from "@/app/(app)/share/[sharingKey]/action"; +import { notFound } from "next/navigation"; + +import { RESPONSES_PER_PAGE, REVALIDATION_INTERVAL, WEBAPP_URL } from "@formbricks/lib/constants"; +import { getEnvironment } from "@formbricks/lib/environment/service"; +import { getProductByEnvironmentId } from "@formbricks/lib/product/service"; +import { getSurvey } from "@formbricks/lib/survey/service"; +import { getTagsByEnvironmentId } from "@formbricks/lib/tag/service"; + +export const revalidate = REVALIDATION_INTERVAL; + +export default async function Page({ params }) { + const surveyId = await getResultShareUrlSurveyAction(params.sharingKey); + + if (!surveyId) { + return notFound(); + } + + const survey = await getSurvey(surveyId); + + if (!survey) { + throw new Error("Survey not found"); + } + + const [{ responses }, environment] = await Promise.all([ + getAnalysisData(survey.id, survey.environmentId), + getEnvironment(survey.environmentId), + ]); + + if (!environment) { + throw new Error("Environment not found"); + } + const product = await getProductByEnvironmentId(environment.id); + if (!product) { + throw new Error("Product not found"); + } + + const tags = await getTagsByEnvironmentId(environment.id); + + return ( + <> + + + ); +} diff --git a/apps/web/app/(app)/share/[sharingKey]/(analysis)/summary/components/SummaryPage.tsx b/apps/web/app/(app)/share/[sharingKey]/(analysis)/summary/components/SummaryPage.tsx new file mode 100644 index 0000000000..4114a6da33 --- /dev/null +++ b/apps/web/app/(app)/share/[sharingKey]/(analysis)/summary/components/SummaryPage.tsx @@ -0,0 +1,92 @@ +"use client"; + +import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext"; +import SummaryDropOffs from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryDropOffs"; +import SummaryList from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryList"; +import SummaryMetadata from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryMetadata"; +import CustomFilter from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter"; +import SurveyResultsTabs from "@/app/(app)/share/[sharingKey]/(analysis)/components/SurveyResultsTabs"; +import SummaryHeader from "@/app/(app)/share/[sharingKey]/components/SummaryHeader"; +import { getFilterResponses } from "@/app/lib/surveys/surveys"; +import { useSearchParams } from "next/navigation"; +import { useEffect, useMemo, useState } from "react"; + +import { TEnvironment } from "@formbricks/types/environment"; +import { TProduct } from "@formbricks/types/product"; +import { TResponse } from "@formbricks/types/responses"; +import { TSurvey } from "@formbricks/types/surveys"; +import { TTag } from "@formbricks/types/tags"; +import ContentWrapper from "@formbricks/ui/ContentWrapper"; + +interface SummaryPageProps { + environment: TEnvironment; + survey: TSurvey; + surveyId: string; + responses: TResponse[]; + product: TProduct; + sharingKey: string; + environmentTags: TTag[]; + displayCount: number; + responsesPerPage: number; +} + +const SummaryPage = ({ + environment, + survey, + surveyId, + responses, + product, + sharingKey, + environmentTags, + displayCount, + responsesPerPage: openTextResponsesPerPage, +}: SummaryPageProps) => { + const { selectedFilter, dateRange, resetState } = useResponseFilter(); + const [showDropOffs, setShowDropOffs] = useState(false); + const searchParams = useSearchParams(); + + useEffect(() => { + if (!searchParams?.get("referer")) { + resetState(); + } + }, [searchParams]); + + // get the filtered array when the selected filter value changes + const filterResponses: TResponse[] = useMemo(() => { + return getFilterResponses(responses, selectedFilter, survey, dateRange); + }, [selectedFilter, responses, survey, dateRange]); + + return ( + + + + + + {showDropOffs && } + + + ); +}; + +export default SummaryPage; diff --git a/apps/web/app/(app)/share/[sharingKey]/(analysis)/summary/page.tsx b/apps/web/app/(app)/share/[sharingKey]/(analysis)/summary/page.tsx new file mode 100644 index 0000000000..dcaf4352ce --- /dev/null +++ b/apps/web/app/(app)/share/[sharingKey]/(analysis)/summary/page.tsx @@ -0,0 +1,58 @@ +import { getAnalysisData } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/data"; +import SummaryPage from "@/app/(app)/share/[sharingKey]/(analysis)/summary/components/SummaryPage"; +import { getResultShareUrlSurveyAction } from "@/app/(app)/share/[sharingKey]/action"; +import { notFound } from "next/navigation"; + +import { REVALIDATION_INTERVAL, TEXT_RESPONSES_PER_PAGE } from "@formbricks/lib/constants"; +import { getEnvironment } from "@formbricks/lib/environment/service"; +import { getProductByEnvironmentId } from "@formbricks/lib/product/service"; +import { getSurvey } from "@formbricks/lib/survey/service"; +import { getTagsByEnvironmentId } from "@formbricks/lib/tag/service"; + +export const revalidate = REVALIDATION_INTERVAL; + +export default async function Page({ params }) { + const surveyId = await getResultShareUrlSurveyAction(params.sharingKey); + + if (!surveyId) { + return notFound(); + } + + const survey = await getSurvey(surveyId); + + if (!survey) { + throw new Error("Survey not found"); + } + + const [{ responses, displayCount }, environment] = await Promise.all([ + getAnalysisData(survey.id, survey.environmentId), + getEnvironment(survey.environmentId), + ]); + + if (!environment) { + throw new Error("Environment not found"); + } + + const product = await getProductByEnvironmentId(environment.id); + if (!product) { + throw new Error("Product not found"); + } + + const tags = await getTagsByEnvironmentId(environment.id); + + return ( + <> + + + ); +} diff --git a/apps/web/app/(app)/share/[sharingKey]/action.ts b/apps/web/app/(app)/share/[sharingKey]/action.ts new file mode 100644 index 0000000000..bab2488c7b --- /dev/null +++ b/apps/web/app/(app)/share/[sharingKey]/action.ts @@ -0,0 +1,7 @@ +"use server"; + +import { getSurveyByResultShareKey } from "@formbricks/lib/survey/service"; + +export async function getResultShareUrlSurveyAction(key: string): Promise { + return getSurveyByResultShareKey(key); +} diff --git a/apps/web/app/(app)/share/[sharingKey]/components/CustomFilter.tsx b/apps/web/app/(app)/share/[sharingKey]/components/CustomFilter.tsx new file mode 100755 index 0000000000..5a2a26ee59 --- /dev/null +++ b/apps/web/app/(app)/share/[sharingKey]/components/CustomFilter.tsx @@ -0,0 +1,472 @@ +"use client"; + +import { + DateRange, + useResponseFilter, +} from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext"; +import ResponseFilter from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResponseFilter"; +import { fetchFile } from "@/app/lib/fetchFile"; +import { generateQuestionAndFilterOptions, getTodayDate } from "@/app/lib/surveys/surveys"; +import { createId } from "@paralleldrive/cuid2"; +import { differenceInDays, format, subDays } from "date-fns"; +import { ChevronDown, ChevronUp, DownloadIcon } from "lucide-react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import toast from "react-hot-toast"; + +import { getTodaysDateFormatted } from "@formbricks/lib/time"; +import useClickOutside from "@formbricks/lib/useClickOutside"; +import { TResponse } from "@formbricks/types/responses"; +import { TSurvey } from "@formbricks/types/surveys"; +import { TTag } from "@formbricks/types/tags"; +import { Calendar } from "@formbricks/ui/Calendar"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@formbricks/ui/DropdownMenu"; + +enum DateSelected { + FROM = "from", + TO = "to", +} + +enum FilterDownload { + ALL = "all", + FILTER = "filter", +} + +enum FilterDropDownLabels { + ALL_TIME = "All time", + LAST_7_DAYS = "Last 7 days", + LAST_30_DAYS = "Last 30 days", + CUSTOM_RANGE = "Custom range...", +} + +interface CustomFilterProps { + environmentTags: TTag[]; + survey: TSurvey; + responses: TResponse[]; + totalResponses: TResponse[]; +} + +const getDifferenceOfDays = (from, to) => { + const days = differenceInDays(to, from); + if (days === 7) { + return FilterDropDownLabels.LAST_7_DAYS; + } else if (days === 30) { + return FilterDropDownLabels.LAST_30_DAYS; + } else { + return FilterDropDownLabels.CUSTOM_RANGE; + } +}; + +const CustomFilter = ({ environmentTags, responses, survey, totalResponses }: CustomFilterProps) => { + const { setSelectedOptions, dateRange, setDateRange } = useResponseFilter(); + const [filterRange, setFilterRange] = useState( + dateRange.from && dateRange.to + ? getDifferenceOfDays(dateRange.from, dateRange.to) + : FilterDropDownLabels.ALL_TIME + ); + const [selectingDate, setSelectingDate] = useState(DateSelected.FROM); + const [isDatePickerOpen, setIsDatePickerOpen] = useState(false); + const [isFilterDropDownOpen, setIsFilterDropDownOpen] = useState(false); + const [isDownloadDropDownOpen, setIsDownloadDropDownOpen] = useState(false); + const [hoveredRange, setHoveredRange] = useState(null); + + // when the page loads we get total responses and iterate over the responses and questions, tags and attributes to create the filter options + useEffect(() => { + const { questionFilterOptions, questionOptions } = generateQuestionAndFilterOptions( + survey, + totalResponses, + environmentTags + ); + setSelectedOptions({ questionFilterOptions, questionOptions }); + }, [totalResponses, survey, setSelectedOptions, environmentTags]); + + const datePickerRef = useRef(null); + + const getMatchQandA = (responses: TResponse[], survey: TSurvey) => { + if (survey && responses) { + // Create a mapping of question IDs to their headlines + const questionIdToHeadline = {}; + survey.questions.forEach((question) => { + questionIdToHeadline[question.id] = question.headline; + }); + + // Replace question IDs with question headlines in response data + const updatedResponses = responses.map((response) => { + const updatedResponse: Array<{ + id: string; + question: string; + answer: string; + type: string; + scale?: "number" | "star" | "smiley"; + range?: number; + }> = []; // Specify the type of updatedData + // iterate over survey questions and build the updated response + for (const question of survey.questions) { + const answer = response.data[question.id]; + if (answer) { + updatedResponse.push({ + id: createId(), + question: question.headline, + type: question.type, + scale: question.scale, + range: question.range, + answer: answer as string, + }); + } + } + return { ...response, responses: updatedResponse }; + }); + + const updatedResponsesWithTags = updatedResponses.map((response) => ({ + ...response, + tags: response.tags?.map((tag) => tag), + })); + + return updatedResponsesWithTags; + } + return []; + }; + + const downloadFileName = useMemo(() => { + if (survey) { + const formattedDateString = getTodaysDateFormatted("_"); + return `${survey.name.split(" ").join("_")}_responses_${formattedDateString}`.toLocaleLowerCase(); + } + + return "my_survey_responses"; + }, [survey]); + + function extracMetadataKeys(obj, parentKey = "") { + let keys: string[] = []; + + for (let key in obj) { + if (typeof obj[key] === "object" && obj[key] !== null) { + keys = keys.concat(extracMetadataKeys(obj[key], parentKey + key + " - ")); + } else { + keys.push(parentKey + key); + } + } + + return keys; + } + + const downloadResponses = useCallback( + async (filter: FilterDownload, filetype: "csv" | "xlsx") => { + const downloadResponse = filter === FilterDownload.ALL ? totalResponses : responses; + const questionNames = survey.questions?.map((question) => question.headline); + const hiddenFieldIds = survey.hiddenFields.fieldIds; + const hiddenFieldResponse = {}; + let metaDataFields = extracMetadataKeys(downloadResponse[0].meta); + const userAttributes = ["Init Attribute 1", "Init Attribute 2"]; + const matchQandA = getMatchQandA(downloadResponse, survey); + const jsonData = matchQandA.map((response) => { + const basicInfo = { + "Response ID": response.id, + Timestamp: response.createdAt, + Finished: response.finished, + "Survey ID": response.surveyId, + "Formbricks User ID": response.person?.id ?? "", + }; + const metaDataKeys = extracMetadataKeys(response.meta); + let metaData = {}; + metaDataKeys.forEach((key) => { + if (!metaDataFields.includes(key)) metaDataFields.push(key); + if (response.meta) { + if (key.includes("-")) { + const nestedKeyArray = key.split("-"); + metaData[key] = response.meta[nestedKeyArray[0].trim()][nestedKeyArray[1].trim()] ?? ""; + } else { + metaData[key] = response.meta[key] ?? ""; + } + } + }); + + const personAttributes = response.personAttributes; + if (hiddenFieldIds && hiddenFieldIds.length > 0) { + hiddenFieldIds.forEach((hiddenFieldId) => { + hiddenFieldResponse[hiddenFieldId] = response.data[hiddenFieldId] ?? ""; + }); + } + const fileResponse = { ...basicInfo, ...metaData, ...personAttributes, ...hiddenFieldResponse }; + // Map each question name to its corresponding answer + questionNames.forEach((questionName: string) => { + const matchingQuestion = response.responses.find((question) => question.question === questionName); + let transformedAnswer = ""; + if (matchingQuestion) { + const answer = matchingQuestion.answer; + if (Array.isArray(answer)) { + transformedAnswer = answer.join("; "); + } else { + transformedAnswer = answer; + } + } + fileResponse[questionName] = matchingQuestion ? transformedAnswer : ""; + }); + + return fileResponse; + }); + + // Fields which will be used as column headers in the file + const fields = [ + "Response ID", + "Timestamp", + "Finished", + "Survey ID", + "Formbricks User ID", + ...metaDataFields, + ...questionNames, + ...(hiddenFieldIds ?? []), + ...(survey.type === "web" ? userAttributes : []), + ]; + + let response; + + try { + response = await fetchFile( + { + json: jsonData, + fields, + fileName: downloadFileName, + }, + filetype + ); + } catch (err) { + toast.error(`Error downloading ${filetype === "csv" ? "CSV" : "Excel"}`); + return; + } + + let blob: Blob; + if (filetype === "csv") { + blob = new Blob([response.fileResponse], { type: "text/csv;charset=utf-8;" }); + } else if (filetype === "xlsx") { + const binaryString = atob(response["fileResponse"]); + const byteArray = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + byteArray[i] = binaryString.charCodeAt(i); + } + blob = new Blob([byteArray], { + type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + }); + } else { + throw new Error(`Unsupported filetype: ${filetype}`); + } + + const downloadUrl = URL.createObjectURL(blob); + + const link = document.createElement("a"); + link.href = downloadUrl; + link.download = `${downloadFileName}.${filetype}`; + + document.body.appendChild(link); + link.click(); + + document.body.removeChild(link); + + URL.revokeObjectURL(downloadUrl); + }, + [downloadFileName, responses, totalResponses, survey] + ); + + const handleDateHoveredChange = (date: Date) => { + if (selectingDate === DateSelected.FROM) { + const startOfRange = new Date(date); + startOfRange.setHours(0, 0, 0, 0); // Set to the start of the selected day + + // Check if the selected date is after the current 'to' date + if (startOfRange > dateRange?.to!) { + return; + } else { + setHoveredRange({ from: startOfRange, to: dateRange.to }); + } + } else { + const endOfRange = new Date(date); + endOfRange.setHours(23, 59, 59, 999); // Set to the end of the selected day + + // Check if the selected date is before the current 'from' date + if (endOfRange < dateRange?.from!) { + return; + } else { + setHoveredRange({ from: dateRange.from, to: endOfRange }); + } + } + }; + + const handleDateChange = (date: Date) => { + if (selectingDate === DateSelected.FROM) { + const startOfRange = new Date(date); + startOfRange.setHours(0, 0, 0, 0); // Set to the start of the selected day + + // Check if the selected date is after the current 'to' date + if (startOfRange > dateRange?.to!) { + const nextDay = new Date(startOfRange); + nextDay.setDate(nextDay.getDate() + 1); + nextDay.setHours(23, 59, 59, 999); + setDateRange({ from: startOfRange, to: nextDay }); + } else { + setDateRange((prevData) => ({ from: startOfRange, to: prevData.to })); + } + setSelectingDate(DateSelected.TO); + } else { + const endOfRange = new Date(date); + endOfRange.setHours(23, 59, 59, 999); // Set to the end of the selected day + + // Check if the selected date is before the current 'from' date + if (endOfRange < dateRange?.from!) { + const previousDay = new Date(endOfRange); + previousDay.setDate(previousDay.getDate() - 1); + previousDay.setHours(0, 0, 0, 0); // Set to the start of the selected day + setDateRange({ from: previousDay, to: endOfRange }); + } else { + setDateRange((prevData) => ({ from: prevData?.from, to: endOfRange })); + } + setIsDatePickerOpen(false); + setSelectingDate(DateSelected.FROM); + } + }; + + const handleDatePickerClose = () => { + setIsDatePickerOpen(false); + setSelectingDate(DateSelected.FROM); + }; + + useClickOutside(datePickerRef, () => handleDatePickerClose()); + + return ( + <> +
+
+ + { + value && handleDatePickerClose(); + setIsFilterDropDownOpen(value); + }}> + +
+ + {filterRange === FilterDropDownLabels.CUSTOM_RANGE + ? `${dateRange?.from ? format(dateRange?.from, "dd LLL") : "Select first date"} - ${ + dateRange?.to ? format(dateRange.to, "dd LLL") : "Select last date" + }` + : filterRange} + + {isFilterDropDownOpen ? ( + + ) : ( + + )} +
+
+ + { + setFilterRange(FilterDropDownLabels.ALL_TIME); + setDateRange({ from: undefined, to: getTodayDate() }); + }}> +

All time

+
+ { + setFilterRange(FilterDropDownLabels.LAST_7_DAYS); + setDateRange({ from: subDays(new Date(), 7), to: getTodayDate() }); + }}> +

Last 7 days

+
+ { + setFilterRange(FilterDropDownLabels.LAST_30_DAYS); + setDateRange({ from: subDays(new Date(), 30), to: getTodayDate() }); + }}> +

Last 30 days

+
+ { + setIsDatePickerOpen(true); + setFilterRange(FilterDropDownLabels.CUSTOM_RANGE); + setSelectingDate(DateSelected.FROM); + }}> +

Custom range...

+
+
+
+ { + value && handleDatePickerClose(); + setIsDownloadDropDownOpen(value); + }}> + +
+
+ Download + {isDownloadDropDownOpen ? ( + + ) : ( + + )} +
+ +
+
+ + { + downloadResponses(FilterDownload.ALL, "csv"); + }}> +

All responses (CSV)

+
+ { + downloadResponses(FilterDownload.ALL, "xlsx"); + }}> +

All responses (Excel)

+
+ { + downloadResponses(FilterDownload.FILTER, "csv"); + }}> +

Current selection (CSV)

+
+ { + downloadResponses(FilterDownload.FILTER, "xlsx"); + }}> +

Current selection (Excel)

+
+
+
+
+ {isDatePickerOpen && ( +
+ handleDateChange(date)} + onDayMouseEnter={handleDateHoveredChange} + onDayMouseLeave={() => setHoveredRange(null)} + classNames={{ + day_today: "hover:bg-slate-200 bg-white", + }} + /> +
+ )} +
+ + ); +}; + +export default CustomFilter; diff --git a/apps/web/app/(app)/share/[sharingKey]/components/SummaryHeader.tsx b/apps/web/app/(app)/share/[sharingKey]/components/SummaryHeader.tsx new file mode 100644 index 0000000000..d55fada362 --- /dev/null +++ b/apps/web/app/(app)/share/[sharingKey]/components/SummaryHeader.tsx @@ -0,0 +1,21 @@ +"use client"; + +import { TProduct } from "@formbricks/types/product"; +import { TSurvey } from "@formbricks/types/surveys"; + +interface SummaryHeaderProps { + survey: TSurvey; + product: TProduct; +} +const SummaryHeader = ({ survey, product }: SummaryHeaderProps) => { + return ( +
+
+

{survey.name}

+ {product.name} +
+
+ ); +}; + +export default SummaryHeader; diff --git a/apps/web/app/(app)/share/[sharingKey]/layout.tsx b/apps/web/app/(app)/share/[sharingKey]/layout.tsx new file mode 100644 index 0000000000..480eee48cb --- /dev/null +++ b/apps/web/app/(app)/share/[sharingKey]/layout.tsx @@ -0,0 +1,14 @@ +import { ResponseFilterProvider } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext"; +import { Metadata } from "next"; + +export const metadata: Metadata = { + robots: { index: false, follow: false }, +}; + +export default async function EnvironmentLayout({ children }) { + return ( +
+ {children} +
+ ); +} diff --git a/apps/web/app/(app)/share/[sharingKey]/not-found.tsx b/apps/web/app/(app)/share/[sharingKey]/not-found.tsx new file mode 100644 index 0000000000..07d8aecfb6 --- /dev/null +++ b/apps/web/app/(app)/share/[sharingKey]/not-found.tsx @@ -0,0 +1,20 @@ +import Link from "next/link"; + +import { Button } from "@formbricks/ui/Button"; + +export default function NotFound() { + return ( + <> +
+

404

+

Page not found

+

+ Sorry, we couldn’t find the responses sharing ID you’re looking for. +

+ + + +
+ + ); +} diff --git a/apps/web/app/(app)/share/[sharingKey]/page.tsx b/apps/web/app/(app)/share/[sharingKey]/page.tsx new file mode 100644 index 0000000000..24ce0ffb69 --- /dev/null +++ b/apps/web/app/(app)/share/[sharingKey]/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation"; + +export default function EnvironmentPage({ params }) { + return redirect(`/share/${params.sharingKey}/summary`); +} diff --git a/apps/web/app/(auth)/auth/components/AzureButton.tsx b/apps/web/app/(auth)/auth/components/AzureButton.tsx new file mode 100644 index 0000000000..f9571c73c3 --- /dev/null +++ b/apps/web/app/(auth)/auth/components/AzureButton.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { signIn } from "next-auth/react"; +import { useEffect } from "react"; +import { FaMicrosoft } from "react-icons/fa"; + +import { Button } from "@formbricks/ui/Button"; + +export const AzureButton = ({ + text = "Continue with Azure", + inviteUrl, + directRedirect, +}: { + text?: string; + inviteUrl?: string | null; + directRedirect?: boolean | false; +}) => { + const handleLogin = async () => { + await signIn("azure-ad", { + redirect: true, + callbackUrl: inviteUrl ? inviteUrl : "/", + }); + }; + + useEffect(() => { + if (directRedirect) { + handleLogin(); + } + }, []); + + return ( + + ); +}; diff --git a/apps/web/app/(auth)/auth/components/GithubButton.tsx b/apps/web/app/(auth)/auth/components/GithubButton.tsx index efb1de02f9..0e3fea4d53 100644 --- a/apps/web/app/(auth)/auth/components/GithubButton.tsx +++ b/apps/web/app/(auth)/auth/components/GithubButton.tsx @@ -1,9 +1,10 @@ "use client"; -import { Button } from "@formbricks/ui/Button"; import { signIn } from "next-auth/react"; import { FaGithub } from "react-icons/fa"; +import { Button } from "@formbricks/ui/Button"; + export const GithubButton = ({ text = "Continue with Github", inviteUrl, diff --git a/apps/web/app/(auth)/auth/components/GoogleButton.tsx b/apps/web/app/(auth)/auth/components/GoogleButton.tsx index d06a7f89dc..156d6a67ad 100644 --- a/apps/web/app/(auth)/auth/components/GoogleButton.tsx +++ b/apps/web/app/(auth)/auth/components/GoogleButton.tsx @@ -1,9 +1,10 @@ "use client"; -import { Button } from "@formbricks/ui/Button"; import { signIn } from "next-auth/react"; import { FaGoogle } from "react-icons/fa"; +import { Button } from "@formbricks/ui/Button"; + export const GoogleButton = ({ text = "Continue with Google", inviteUrl, diff --git a/apps/web/app/(auth)/auth/components/IsPasswordValid.tsx b/apps/web/app/(auth)/auth/components/IsPasswordValid.tsx index 49fc81b5a6..2d4a4afa39 100644 --- a/apps/web/app/(auth)/auth/components/IsPasswordValid.tsx +++ b/apps/web/app/(auth)/auth/components/IsPasswordValid.tsx @@ -1,5 +1,5 @@ import { CheckIcon } from "@heroicons/react/24/solid"; -import React, { useState, useEffect } from "react"; +import React, { useEffect, useState } from "react"; interface Validation { label: string; diff --git a/apps/web/app/(auth)/auth/components/Testimonial.tsx b/apps/web/app/(auth)/auth/components/Testimonial.tsx index a9a506bb4c..49ba9166c3 100644 --- a/apps/web/app/(auth)/auth/components/Testimonial.tsx +++ b/apps/web/app/(auth)/auth/components/Testimonial.tsx @@ -1,7 +1,7 @@ +import CalComLogo from "@/images/cal-logo-light.svg"; +import Peer from "@/images/peer.webp"; import { CheckCircleIcon } from "@heroicons/react/24/solid"; import Image from "next/image"; -import Peer from "@/images/peer.webp"; -import CalComLogo from "@/images/cal-logo-light.svg"; export default function Testimonial() { return ( diff --git a/apps/web/app/(auth)/auth/forgot-password/components/PasswordResetForm/index.tsx b/apps/web/app/(auth)/auth/forgot-password/components/PasswordResetForm/index.tsx index 068fcffcfc..443c54c8f1 100644 --- a/apps/web/app/(auth)/auth/forgot-password/components/PasswordResetForm/index.tsx +++ b/apps/web/app/(auth)/auth/forgot-password/components/PasswordResetForm/index.tsx @@ -1,11 +1,12 @@ "use client"; import { forgotPassword } from "@/app/lib/users/users"; -import { Button } from "@formbricks/ui/Button"; import { XCircleIcon } from "@heroicons/react/24/solid"; import { useRouter } from "next/navigation"; import { useState } from "react"; +import { Button } from "@formbricks/ui/Button"; + export const PasswordResetForm = ({}) => { const router = useRouter(); const [error, setError] = useState(""); diff --git a/apps/web/app/(auth)/auth/forgot-password/reset/components/ResetPasswordForm/index.tsx b/apps/web/app/(auth)/auth/forgot-password/reset/components/ResetPasswordForm/index.tsx index 722b96fd17..5f6224642e 100644 --- a/apps/web/app/(auth)/auth/forgot-password/reset/components/ResetPasswordForm/index.tsx +++ b/apps/web/app/(auth)/auth/forgot-password/reset/components/ResetPasswordForm/index.tsx @@ -1,23 +1,30 @@ "use client"; +import IsPasswordValid from "@/app/(auth)/auth/components/IsPasswordValid"; import { resetPassword } from "@/app/lib/users/users"; -import { PasswordInput } from "@formbricks/ui/PasswordInput"; -import { Button } from "@formbricks/ui/Button"; import { XCircleIcon } from "@heroicons/react/24/solid"; import { useRouter, useSearchParams } from "next/navigation"; import { useState } from "react"; -import IsPasswordValid from "@/app/(auth)/auth/components/IsPasswordValid"; +import { toast } from "react-hot-toast"; + +import { Button } from "@formbricks/ui/Button"; +import { PasswordInput } from "@formbricks/ui/PasswordInput"; export const ResetPasswordForm = () => { const searchParams = useSearchParams(); const router = useRouter(); const [error, setError] = useState(""); const [password, setPassword] = useState(null); + const [confirmPassword, setConfirmPassword] = useState(null); const [isValid, setIsValid] = useState(false); const [loading, setLoading] = useState(false); const handleSubmit = async (e) => { e.preventDefault(); + if (password !== confirmPassword) { + toast.error("Passwords do not match"); + return; + } setLoading(true); const token = searchParams?.get("token"); try { @@ -50,23 +57,39 @@ export const ResetPasswordForm = () => {
)}
-
- -
+
+
+ setPassword(e.target.value)} autoComplete="current-password" placeholder="*******" required - className="focus:border-brand focus:ring-brand block w-full rounded-md border-slate-300 shadow-sm sm:text-sm" + className="focus:border-brand focus:ring-brand mt-2 block w-full rounded-md border-slate-300 shadow-sm sm:text-sm" /> -
+
+ + setConfirmPassword(e.target.value)} + autoComplete="current-password" + placeholder="*******" + required + className="focus:border-brand focus:ring-brand mt-2 block w-full rounded-md border-slate-300 shadow-sm sm:text-sm" + /> +
+ +
diff --git a/apps/web/app/(auth)/auth/layout.tsx b/apps/web/app/(auth)/auth/layout.tsx index 38108b3e12..9825cda433 100644 --- a/apps/web/app/(auth)/auth/layout.tsx +++ b/apps/web/app/(auth)/auth/layout.tsx @@ -1,16 +1,22 @@ import { getServerSession } from "next-auth"; import { redirect } from "next/navigation"; +import { Toaster } from "react-hot-toast"; + +import { authOptions } from "@formbricks/lib/authOptions"; export default async function AuthLayout({ children }: { children: React.ReactNode }) { - const session = await getServerSession(); + const session = await getServerSession(authOptions); if (session) { redirect(`/`); } return ( -
-
-
{children}
+ <> + +
+
+
{children}
+
-
+ ); } diff --git a/apps/web/app/(auth)/auth/login/components/SigninForm.tsx b/apps/web/app/(auth)/auth/login/components/SigninForm.tsx index e5aa71d9a6..11cfee4118 100644 --- a/apps/web/app/(auth)/auth/login/components/SigninForm.tsx +++ b/apps/web/app/(auth)/auth/login/components/SigninForm.tsx @@ -1,19 +1,20 @@ "use client"; -import { PasswordInput } from "@formbricks/ui/PasswordInput"; -import { Button } from "@formbricks/ui/Button"; -import { XCircleIcon } from "@heroicons/react/24/solid"; -import { signIn } from "next-auth/react"; -import Link from "next/dist/client/link"; -import { useRouter, useSearchParams } from "next/navigation"; -import { useMemo, useRef, useState } from "react"; -import { Controller, SubmitHandler, useForm, FormProvider } from "react-hook-form"; - -import { cn } from "@formbricks/lib/cn"; +import { AzureButton } from "@/app/(auth)/auth/components/AzureButton"; import { GithubButton } from "@/app/(auth)/auth/components/GithubButton"; import { GoogleButton } from "@/app/(auth)/auth/components/GoogleButton"; import TwoFactor from "@/app/(auth)/auth/login/components/TwoFactor"; import TwoFactorBackup from "@/app/(auth)/auth/login/components/TwoFactorBackup"; +import { XCircleIcon } from "@heroicons/react/24/solid"; +import { signIn } from "next-auth/react"; +import Link from "next/dist/client/link"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { Controller, FormProvider, SubmitHandler, useForm } from "react-hook-form"; + +import { cn } from "@formbricks/lib/cn"; +import { Button } from "@formbricks/ui/Button"; +import { PasswordInput } from "@formbricks/ui/PasswordInput"; type TSigninFormState = { email: string; @@ -27,11 +28,13 @@ export const SigninForm = ({ passwordResetEnabled, googleOAuthEnabled, githubOAuthEnabled, + azureOAuthEnabled, }: { publicSignUpEnabled: boolean; passwordResetEnabled: boolean; googleOAuthEnabled: boolean; githubOAuthEnabled: boolean; + azureOAuthEnabled: boolean; }) => { const router = useRouter(); const searchParams = useSearchParams(); @@ -45,7 +48,7 @@ export const SigninForm = ({ try { const signInResponse = await signIn("credentials", { callbackUrl: searchParams?.get("callbackUrl") || "/", - email: data.email, + email: data.email.toLowerCase(), password: data.password, ...(totpLogin && { totpCode: data.totpCode }), ...(totpBackup && { backupCode: data.backupCode }), @@ -85,10 +88,16 @@ export const SigninForm = ({ const [totpBackup, setTotpBackup] = useState(false); const [signInError, setSignInError] = useState(""); const formRef = useRef(null); - + const error = searchParams?.get("error"); const callbackUrl = searchParams?.get("callbackUrl"); const inviteToken = callbackUrl ? new URL(callbackUrl).searchParams.get("token") : null; + useEffect(() => { + if (error) { + setSignInError(error); + } + }, []); + const formLabel = useMemo(() => { if (totpBackup) { return "Enter your backup code"; @@ -204,6 +213,12 @@ export const SigninForm = ({ )} + + {azureOAuthEnabled && !totpLogin && ( + <> + + + )}
{publicSignUpEnabled && !totpLogin && ( diff --git a/apps/web/app/(auth)/auth/login/components/TwoFactor.tsx b/apps/web/app/(auth)/auth/login/components/TwoFactor.tsx index 8295a742c5..5059694d02 100644 --- a/apps/web/app/(auth)/auth/login/components/TwoFactor.tsx +++ b/apps/web/app/(auth)/auth/login/components/TwoFactor.tsx @@ -2,6 +2,7 @@ import React from "react"; import { Controller, useFormContext } from "react-hook-form"; + import { OTPInput } from "@formbricks/ui/OTPInput"; const TwoFactor = () => { diff --git a/apps/web/app/(auth)/auth/login/components/TwoFactorBackup.tsx b/apps/web/app/(auth)/auth/login/components/TwoFactorBackup.tsx index 9b66307c07..929145a53a 100644 --- a/apps/web/app/(auth)/auth/login/components/TwoFactorBackup.tsx +++ b/apps/web/app/(auth)/auth/login/components/TwoFactorBackup.tsx @@ -1,9 +1,10 @@ "use client"; -import { Input } from "@formbricks/ui/Input"; import React from "react"; import { useFormContext } from "react-hook-form"; +import { Input } from "@formbricks/ui/Input"; + const TwoFactorBackup = () => { const { register } = useFormContext(); diff --git a/apps/web/app/(auth)/auth/login/page.tsx b/apps/web/app/(auth)/auth/login/page.tsx index f76471780b..a99f34a318 100644 --- a/apps/web/app/(auth)/auth/login/page.tsx +++ b/apps/web/app/(auth)/auth/login/page.tsx @@ -1,13 +1,15 @@ +import FormWrapper from "@/app/(auth)/auth/components/FormWrapper"; +import Testimonial from "@/app/(auth)/auth/components/Testimonial"; +import { SigninForm } from "@/app/(auth)/auth/login/components/SigninForm"; import { Metadata } from "next"; + import { + AZURE_OAUTH_ENABLED, GITHUB_OAUTH_ENABLED, GOOGLE_OAUTH_ENABLED, PASSWORD_RESET_DISABLED, SIGNUP_ENABLED, } from "@formbricks/lib/constants"; -import { SigninForm } from "@/app/(auth)/auth/login/components/SigninForm"; -import Testimonial from "@/app/(auth)/auth/components/Testimonial"; -import FormWrapper from "@/app/(auth)/auth/components/FormWrapper"; export const metadata: Metadata = { title: "Login", @@ -27,6 +29,7 @@ export default function SignInPage() { passwordResetEnabled={!PASSWORD_RESET_DISABLED} googleOAuthEnabled={GOOGLE_OAUTH_ENABLED} githubOAuthEnabled={GITHUB_OAUTH_ENABLED} + azureOAuthEnabled={AZURE_OAUTH_ENABLED} />
diff --git a/apps/web/app/(auth)/auth/signup/components/SignupForm.tsx b/apps/web/app/(auth)/auth/signup/components/SignupForm.tsx index f56aee2b1c..2bfd4d73a6 100644 --- a/apps/web/app/(auth)/auth/signup/components/SignupForm.tsx +++ b/apps/web/app/(auth)/auth/signup/components/SignupForm.tsx @@ -1,15 +1,17 @@ "use client"; +import { AzureButton } from "@/app/(auth)/auth/components/AzureButton"; +import { GithubButton } from "@/app/(auth)/auth/components/GithubButton"; +import { GoogleButton } from "@/app/(auth)/auth/components/GoogleButton"; +import IsPasswordValid from "@/app/(auth)/auth/components/IsPasswordValid"; import { createUser } from "@/app/lib/users/users"; -import { PasswordInput } from "@formbricks/ui/PasswordInput"; -import { Button } from "@formbricks/ui/Button"; import { XCircleIcon } from "@heroicons/react/24/solid"; import Link from "next/link"; import { useRouter, useSearchParams } from "next/navigation"; import { useMemo, useRef, useState } from "react"; -import { GithubButton } from "@/app/(auth)/auth/components/GithubButton"; -import { GoogleButton } from "@/app/(auth)/auth/components/GoogleButton"; -import IsPasswordValid from "@/app/(auth)/auth/components/IsPasswordValid"; + +import { Button } from "@formbricks/ui/Button"; +import { PasswordInput } from "@formbricks/ui/PasswordInput"; export const SignupForm = ({ webAppUrl, @@ -19,6 +21,7 @@ export const SignupForm = ({ emailVerificationDisabled, googleOAuthEnabled, githubOAuthEnabled, + azureOAuthEnabled, }: { webAppUrl: string; privacyUrl: string | undefined; @@ -27,6 +30,7 @@ export const SignupForm = ({ emailVerificationDisabled: boolean; googleOAuthEnabled: boolean; githubOAuthEnabled: boolean; + azureOAuthEnabled: boolean; }) => { const searchParams = useSearchParams(); const router = useRouter(); @@ -199,6 +203,11 @@ export const SignupForm = ({ )} + {azureOAuthEnabled && ( + <> + + + )}
{(termsUrl || privacyUrl) && ( diff --git a/apps/web/app/(auth)/auth/signup/page.tsx b/apps/web/app/(auth)/auth/signup/page.tsx index 1c72b2616e..e2fa8c64bb 100644 --- a/apps/web/app/(auth)/auth/signup/page.tsx +++ b/apps/web/app/(auth)/auth/signup/page.tsx @@ -1,6 +1,10 @@ +import FormWrapper from "@/app/(auth)/auth/components/FormWrapper"; +import Testimonial from "@/app/(auth)/auth/components/Testimonial"; +import { SignupForm } from "@/app/(auth)/auth/signup/components/SignupForm"; import Link from "next/link"; import { + AZURE_OAUTH_ENABLED, EMAIL_VERIFICATION_DISABLED, GITHUB_OAUTH_ENABLED, GOOGLE_OAUTH_ENABLED, @@ -11,9 +15,6 @@ import { TERMS_URL, WEBAPP_URL, } from "@formbricks/lib/constants"; -import { SignupForm } from "@/app/(auth)/auth/signup/components/SignupForm"; -import Testimonial from "@/app/(auth)/auth/components/Testimonial"; -import FormWrapper from "@/app/(auth)/auth/components/FormWrapper"; export default function SignUpPage({ searchParams, @@ -52,6 +53,7 @@ export default function SignUpPage({ emailVerificationDisabled={EMAIL_VERIFICATION_DISABLED} googleOAuthEnabled={GOOGLE_OAUTH_ENABLED} githubOAuthEnabled={GITHUB_OAUTH_ENABLED} + azureOAuthEnabled={AZURE_OAUTH_ENABLED} /> )} diff --git a/apps/web/app/(auth)/auth/verification-requested/components/RequestVerificationEmail.tsx b/apps/web/app/(auth)/auth/verification-requested/components/RequestVerificationEmail.tsx index e73951303d..be2d85eeff 100644 --- a/apps/web/app/(auth)/auth/verification-requested/components/RequestVerificationEmail.tsx +++ b/apps/web/app/(auth)/auth/verification-requested/components/RequestVerificationEmail.tsx @@ -1,14 +1,30 @@ "use client"; -import { Button } from "@formbricks/ui/Button"; import { resendVerificationEmail } from "@/app/lib/users/users"; +import { useEffect } from "react"; import toast from "react-hot-toast"; +import { Button } from "@formbricks/ui/Button"; + interface RequestEmailVerificationProps { email: string | null; } export const RequestVerificationEmail = ({ email }: RequestEmailVerificationProps) => { + useEffect(() => { + const handleVisibilityChange = () => { + if (document.visibilityState === "visible") { + location.reload(); + } + }; + + document.addEventListener("visibilitychange", handleVisibilityChange); + + return () => { + document.removeEventListener("visibilitychange", handleVisibilityChange); + }; + }, []); + const requestVerificationEmail = async () => { try { if (!email) throw new Error("No email provided"); @@ -18,6 +34,7 @@ export const RequestVerificationEmail = ({ email }: RequestEmailVerificationProp toast.error(`Error: ${e.message}`); } }; + return ( <> +
+ + + ); +} diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 21b84e24fe..90491ca623 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -2,6 +2,55 @@ @tailwind components; @tailwind utilities; +:root { + /* Brand Colors */ + --formbricks-brand: #038178; + + /* Fill Colors */ + --formbricks-fill-primary: #fefefe; + --formbricks-fill-secondary: #0f172a; + --formbricks-fill-disabled: #e0e0e0; + + /* Label Colors */ + --formbricks-label-primary: #0f172a; + --formbricks-label-secondary: #384258; + --formbricks-label-disabled: #bdbdbd; + + /* Border Colors */ + --formbricks-border-primary: #e0e0e0; + --formbricks-border-secondary: #0f172a; + --formbricks-border-disabled: #ececec; + + /* Functional Colors */ + --formbricks-focus: #1982fc; + --formbricks-error: #d13a3a; +} + +.dark { + /* Brand Colors */ + --formbricks-brand: #038178; + + /* Fill Colors */ + --formbricks-fill-primary: #0f172a; + --formbricks-fill-secondary: #e0e0e0; + --formbricks-fill-disabled: #394258; + + /* Label Colors */ + --formbricks-label-primary: #fefefe; + --formbricks-label-secondary: #f2f2f2; + --formbricks-label-disabled: #bdbdbd; + + /* Border Colors */ + --formbricks-border-primary: #394258; + --formbricks-border-secondary: #e0e0e0; + --formbricks-border-disabled: #394258; + + /* Functional Colors */ + --formbricks-focus: #1982fc; + --formbricks-error: #d13a3a; +} + + @layer base { [data-nextjs-scroll-focus-boundary] { display: contents; @@ -36,7 +85,7 @@ input:focus { } @layer utilities { - @variants responsive { + @layer responsive { /* Hide scrollbar for Chrome, Safari and Opera */ .no-scrollbar::-webkit-scrollbar { display: none; @@ -61,4 +110,4 @@ input[type="search"]::-ms-clear { input[type="search"]::-ms-reveal { display: none; -} \ No newline at end of file +} diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 883da12beb..a2d6e4d5c2 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -1,18 +1,19 @@ -import "./globals.css"; import { Metadata } from "next"; +import "./globals.css"; + export const metadata: Metadata = { title: { template: "%s | Formbricks", default: "Formbricks", }, - description: "Open-Source In-Product Survey Platform", + description: "Open-Source Survey Suite", }; export default function RootLayout({ children }: { children: React.ReactNode }) { return ( - {children} + {children} ); } diff --git a/apps/web/app/lib/api/apiHelper.ts b/apps/web/app/lib/api/apiHelper.ts index 89efd9d1cb..b481faf150 100644 --- a/apps/web/app/lib/api/apiHelper.ts +++ b/apps/web/app/lib/api/apiHelper.ts @@ -1,11 +1,12 @@ -import { prisma } from "@formbricks/database"; -import { authOptions } from "@formbricks/lib/authOptions"; -import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth"; import { createHash } from "crypto"; import { NextApiRequest, NextApiResponse } from "next"; import type { Session } from "next-auth"; import { getServerSession } from "next-auth"; +import { prisma } from "@formbricks/database"; +import { authOptions } from "@formbricks/lib/authOptions"; +import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth"; + export const hashApiKey = (key: string): string => createHash("sha256").update(key).digest("hex"); export const hasEnvironmentAccess = async ( diff --git a/apps/web/app/lib/api/clientPerson.ts b/apps/web/app/lib/api/clientPerson.ts deleted file mode 100644 index 0075c3d52f..0000000000 --- a/apps/web/app/lib/api/clientPerson.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { prisma } from "@formbricks/database"; -import type { Person } from "@formbricks/types/js"; - -const select = { - id: true, - environmentId: true, - attributes: { - select: { - id: true, - value: true, - attributeClass: { - select: { - id: true, - name: true, - }, - }, - }, - }, -}; - -export const createPerson = async (environmentId: string): Promise => { - return await prisma.person.create({ - data: { - environment: { - connect: { - id: environmentId, - }, - }, - }, - select, - }); -}; diff --git a/apps/web/app/lib/api/clientSession.ts b/apps/web/app/lib/api/clientSession.ts deleted file mode 100644 index e73a03c911..0000000000 --- a/apps/web/app/lib/api/clientSession.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { prisma } from "@formbricks/database"; -import { Session } from "@formbricks/types/js"; - -export const createSession = async (personId: string): Promise => { - return prisma.session.create({ - data: { - person: { - connect: { - id: personId, - }, - }, - }, - select: { - id: true, - }, - }); -}; diff --git a/apps/web/app/lib/api/clientSettings.ts b/apps/web/app/lib/api/clientSettings.ts index 7f61948f4d..a1d2e4d3e9 100644 --- a/apps/web/app/lib/api/clientSettings.ts +++ b/apps/web/app/lib/api/clientSettings.ts @@ -1,7 +1,7 @@ import { prisma } from "@formbricks/database"; -import { Settings } from "@formbricks/types/js"; +import { TSettings } from "@formbricks/types/js"; -export const getSettings = async (environmentId: string, personId: string): Promise => { +export const getSettings = async (environmentId: string, personId: string): Promise => { // get recontactDays from product const product = await prisma.product.findFirst({ where: { @@ -72,7 +72,7 @@ export const getSettings = async (environmentId: string, personId: string): Prom triggers: { select: { id: true, - eventClass: { + actionClass: { select: { id: true, name: true, @@ -181,7 +181,7 @@ export const getSettings = async (environmentId: string, personId: string): Prom return { id: survey.id, questions: JSON.parse(JSON.stringify(survey.questions)), - triggers: survey.triggers, + triggers: survey.triggers.map((trigger) => trigger.actionClass.name), thankYouCard: JSON.parse(JSON.stringify(survey.thankYouCard)), welcomeCard: JSON.parse(JSON.stringify(survey.welcomeCard)), autoClose: survey.autoClose, @@ -189,7 +189,7 @@ export const getSettings = async (environmentId: string, personId: string): Prom }; }); - const noCodeEvents = await prisma.eventClass.findMany({ + const noCodeEvents = await prisma.actionClass.findMany({ where: { environmentId, type: "noCode", @@ -208,7 +208,7 @@ export const getSettings = async (environmentId: string, personId: string): Prom product: { select: { brandColor: true, - formbricksSignature: true, + linkSurveyBranding: true, placement: true, darkOverlay: true, clickOutsideClose: true, @@ -217,7 +217,7 @@ export const getSettings = async (environmentId: string, personId: string): Prom }, }); - const formbricksSignature = environmentProdut?.product.formbricksSignature; + const formbricksSignature = environmentProdut?.product.linkSurveyBranding; const brandColor = environmentProdut?.product.brandColor; const placement = environmentProdut?.product.placement; const darkOverlay = environmentProdut?.product.darkOverlay; diff --git a/apps/web/app/lib/email-template.ts b/apps/web/app/lib/email-template.ts deleted file mode 100644 index e3e3bae902..0000000000 --- a/apps/web/app/lib/email-template.ts +++ /dev/null @@ -1,219 +0,0 @@ -export const withEmailTemplate = (content: string) => - ` - - - - - - - - - -
- - Formbricks Logo -
-
- ${content} -
- - - - - `; diff --git a/apps/web/app/lib/email.ts b/apps/web/app/lib/email.ts deleted file mode 100644 index 02ffdb6d4b..0000000000 --- a/apps/web/app/lib/email.ts +++ /dev/null @@ -1,190 +0,0 @@ -import { getQuestionResponseMapping } from "@/app/lib/responses/questionResponseMapping"; -import { - MAIL_FROM, - SMTP_HOST, - SMTP_PASSWORD, - SMTP_PORT, - SMTP_SECURE_ENABLED, - SMTP_USER, - WEBAPP_URL, -} from "@formbricks/lib/constants"; -import { createInviteToken, createToken, createTokenForLinkSurvey } from "@formbricks/lib/jwt"; -import { Question } from "@formbricks/types/questions"; -import { TResponse } from "@formbricks/types/v1/responses"; -import { withEmailTemplate } from "./email-template"; - -const nodemailer = require("nodemailer"); - -interface sendEmailData { - to: string; - replyTo?: string; - subject: string; - text?: string; - html: string; -} - -export const sendEmail = async (emailData: sendEmailData) => { - let transporter = nodemailer.createTransport({ - host: SMTP_HOST, - port: SMTP_PORT, - secure: SMTP_SECURE_ENABLED, // true for 465, false for other ports - auth: { - user: SMTP_USER, - pass: SMTP_PASSWORD, - }, - // logger: true, - // debug: true, - }); - const emailDefaults = { - from: `Formbricks <${MAIL_FROM || "noreply@formbricks.com"}>`, - }; - await transporter.sendMail({ ...emailDefaults, ...emailData }); -}; - -export const sendVerificationEmail = async (user) => { - const token = createToken(user.id, user.email, { - expiresIn: "1d", - }); - const verifyLink = `${WEBAPP_URL}/auth/verify?token=${encodeURIComponent(token)}`; - const verificationRequestLink = `${WEBAPP_URL}/auth/verification-requested?email=${encodeURIComponent( - user.email - )}`; - await sendEmail({ - to: user.email, - subject: "Welcome to Formbricks 🤍", - html: withEmailTemplate(`

Welcome!

- To start using Formbricks please verify your email by clicking the button below:

- Confirm email
-
- The link is valid for 24h.

If it has expired please request a new token here: - Request new verification
-
- Your Formbricks Team`), - }); -}; - -export const sendLinkSurveyToVerifiedEmail = async (data) => { - const surveyId = data.surveyId; - const email = data.email; - const surveyData = data.surveyData; - const token = createTokenForLinkSurvey(surveyId, email); - const surveyLink = `${WEBAPP_URL}/s/${surveyId}?verify=${encodeURIComponent(token)}`; - await sendEmail({ - to: data.email, - subject: "Your Formbricks Survey", - html: withEmailTemplate(`

Hey 👋

- Thanks for validating your email. Here is your Survey.

- ${surveyData.name} -

${surveyData.subheading}

- Take survey
-
- All the best,
- Your Formbricks Team 🤍`), - }); -}; - -export const sendForgotPasswordEmail = async (user) => { - const token = createToken(user.id, user.email, { - expiresIn: "1d", - }); - const verifyLink = `${WEBAPP_URL}/auth/forgot-password/reset?token=${encodeURIComponent(token)}`; - await sendEmail({ - to: user.email, - subject: "Reset your Formbricks password", - html: withEmailTemplate(`

Change password

- You have requested a link to change your password. You can do this by clicking the link below:

- Change password
-
- The link is valid for 24 hours.

If you didn't request this, please ignore this email.
- Your Formbricks Team`), - }); -}; - -export const sendPasswordResetNotifyEmail = async (user) => { - await sendEmail({ - to: user.email, - subject: "Your Formbricks password has been changed", - html: withEmailTemplate(`

Password changed

- Your password has been changed successfully.
-
- Your Formbricks Team`), - }); -}; - -export const sendInviteMemberEmail = async (inviteId, inviterName, inviteeName, email) => { - const token = createInviteToken(inviteId, email, { - expiresIn: "7d", - }); - const verifyLink = `${WEBAPP_URL}/invite?token=${encodeURIComponent(token)}`; - - await sendEmail({ - to: email, - subject: `You're invited to collaborate on Formbricks!`, - html: withEmailTemplate(`Hey ${inviteeName},

- Your colleague ${inviterName} invited you to join them at Formbricks. To accept the invitation, please click the link below:

- Join team
-
- Have a great day!
- The Formbricks Team!`), - }); -}; - -export const sendInviteAcceptedEmail = async (inviterName, inviteeName, email) => { - await sendEmail({ - to: email, - subject: `You've got a new team member!`, - html: withEmailTemplate(`Hey ${inviterName}, -

- Just letting you know that ${inviteeName} accepted your invitation. Have fun collaborating! -

- Have a great day!
- The Formbricks Team!`), - }); -}; - -export const sendResponseFinishedEmail = async ( - email: string, - environmentId: string, - survey: { id: string; name: string; questions: Question[] }, - response: TResponse -) => { - const personEmail = response.person?.attributes["email"]; - await sendEmail({ - to: email, - subject: personEmail - ? `${personEmail} just completed your ${survey.name} survey ✅` - : `A response for ${survey.name} was completed ✅`, - replyTo: personEmail?.toString() || MAIL_FROM, - html: withEmailTemplate(`

Hey 👋

Someone just completed your survey ${ - survey.name - }
- -
- - ${getQuestionResponseMapping(survey, response) - .map( - (question) => - question.answer && - `
-

${question.question}

-

${question.answer}

-
` - ) - .join("")} - - - View all responses - -
-

Start a conversation 💡

- ${ - personEmail - ? `

Hit 'Reply' or reach out manually: ${personEmail}

` - : "

If you set the email address as an attribute in in-app surveys, you can reply directly to the respondent.

" - } -
- `), - }); -}; diff --git a/apps/web/app/lib/formbricks.ts b/apps/web/app/lib/formbricks.ts index 24a26cad37..27f96310c9 100644 --- a/apps/web/app/lib/formbricks.ts +++ b/apps/web/app/lib/formbricks.ts @@ -1,21 +1,23 @@ import formbricks from "@formbricks/js"; -import { env } from "@/env.mjs"; +import { env } from "@formbricks/lib/env.mjs"; export const formbricksEnabled = typeof env.NEXT_PUBLIC_FORMBRICKS_API_HOST && env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID; +const ttc = { onboarding: 0 }; export const createResponse = async ( surveyId: string, + userId: string, data: { [questionId: string]: any }, finished: boolean = false ): Promise => { const api = formbricks.getApi(); - const personId = formbricks.getPerson()?.id; return await api.client.response.create({ surveyId, - personId, + userId, finished, data, + ttc, }); }; @@ -29,6 +31,7 @@ export const updateResponse = async ( responseId, finished, data, + ttc, }); }; diff --git a/apps/web/app/lib/members.ts b/apps/web/app/lib/members.ts deleted file mode 100644 index a48a1cac9e..0000000000 --- a/apps/web/app/lib/members.ts +++ /dev/null @@ -1,113 +0,0 @@ -export const updateMemberRole = async (teamId: string, userId: string, role: string) => { - try { - const result = await fetch(`/api/v1/teams/${teamId}/members/${userId}/`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ role }), - }); - return result.status === 200; - } catch (error) { - console.error(error); - return false; - } -}; - -export const transferOwnership = async (teamId: string, userId: string) => { - try { - const result = await fetch(`/api/v1/teams/${teamId}/transfer-ownership/`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ userId }), - }); - return result.status === 200; - } catch (error) { - console.error(error); - return false; - } -}; - -export const removeMember = async (teamId: string, userId: string) => { - try { - const result = await fetch(`/api/v1/teams/${teamId}/members/${userId}/`, { - method: "DELETE", - headers: { "Content-Type": "application/json" }, - }); - return result.status === 200; - } catch (error) { - console.error(error); - return false; - } -}; - -// update invitee's role -export const updateInviteeRole = async (teamId: string, inviteId: string, role: string) => { - try { - const result = await fetch(`/api/v1/teams/${teamId}/invite/${inviteId}/`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ role }), - }); - return result.status === 200; - } catch (error) { - console.error(error); - return false; - } -}; - -export const deleteInvite = async (teamId: string, inviteId: string) => { - try { - const result = await fetch(`/api/v1/teams/${teamId}/invite/${inviteId}/`, { - method: "DELETE", - headers: { "Content-Type": "application/json" }, - }); - return result.status === 200; - } catch (error) { - console.error(error); - return false; - } -}; - -export const addMember = async (teamId: string, data: { name: string; email: string }) => { - try { - const result = await fetch(`/api/v1/teams/${teamId}/invite/`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(data), - }); - return result.status === 201; - } catch (error) { - console.error(error); - return false; - } -}; - -export const resendInvite = async (teamId: string, inviteId: string) => { - try { - const result = await fetch(`/api/v1/teams/${teamId}/invite/${inviteId}`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - }); - return result.status === 200; - } catch (error) { - console.error(error); - return false; - } -}; - -export const shareInvite = async (teamId: string, inviteId: string) => { - try { - const res = await fetch(`/api/v1/teams/${teamId}/invite/${inviteId}`, { - method: "GET", - headers: { "Content-Type": "application/json" }, - }); - - if (res.status !== 200) { - const json = await res.json(); - throw Error(json.message); - } - return res.json(); - } catch (error) { - console.error(error); - throw Error(`shareInvite: unable to get invite link: ${error.message}`); - } -}; diff --git a/apps/web/app/lib/pipelines.ts b/apps/web/app/lib/pipelines.ts index a0b7111755..7e807b0dea 100644 --- a/apps/web/app/lib/pipelines.ts +++ b/apps/web/app/lib/pipelines.ts @@ -1,5 +1,5 @@ import { INTERNAL_SECRET, WEBAPP_URL } from "@formbricks/lib/constants"; -import { TPipelineInput } from "@formbricks/types/v1/pipelines"; +import { TPipelineInput } from "@formbricks/types/pipelines"; export async function sendToPipeline({ event, surveyId, environmentId, response }: TPipelineInput) { return fetch(`${WEBAPP_URL}/api/pipeline`, { diff --git a/apps/web/app/lib/preview.ts b/apps/web/app/lib/preview.ts index ec604d948b..dc885612cc 100644 --- a/apps/web/app/lib/preview.ts +++ b/apps/web/app/lib/preview.ts @@ -1,6 +1,6 @@ -import { PlacementType } from "@formbricks/types/js"; +import { TPlacement } from "@formbricks/types/common"; -export const getPlacementStyle = (placement: PlacementType) => { +export const getPlacementStyle = (placement: TPlacement) => { switch (placement) { case "bottomRight": return "bottom-3 sm:right-3"; diff --git a/apps/web/app/lib/profile.ts b/apps/web/app/lib/profile.ts deleted file mode 100644 index 05e3e0e869..0000000000 --- a/apps/web/app/lib/profile.ts +++ /dev/null @@ -1,26 +0,0 @@ -import useSWR from "swr"; -import { fetcher } from "@formbricks/lib/fetcher"; - -export const useProfile = () => { - const { data, isLoading, error, mutate, isValidating } = useSWR(`/api/v1/users/me/`, fetcher); - - return { - profile: data, - isLoadingProfile: isLoading, - isErrorProfile: error, - isValidatingProfile: isValidating, - mutateProfile: mutate, - }; -}; - -export const updateProfile = async (profile) => { - try { - await fetch(`/api/v1/users/me/`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(profile), - }); - } catch (error) { - console.error(error); - } -}; diff --git a/apps/web/app/lib/questions.ts b/apps/web/app/lib/questions.ts index 3e794c176f..2e5acf7e15 100644 --- a/apps/web/app/lib/questions.ts +++ b/apps/web/app/lib/questions.ts @@ -1,17 +1,23 @@ import { - CursorArrowRippleIcon, + ArrowUpTrayIcon, + CalendarDaysIcon, ChatBubbleBottomCenterTextIcon, + CheckIcon, + CursorArrowRippleIcon, ListBulletIcon, + PhoneIcon, + PhotoIcon, PresentationChartBarIcon, QueueListIcon, StarIcon, - CheckIcon, } from "@heroicons/react/24/solid"; import { createId } from "@paralleldrive/cuid2"; -import { replaceQuestionPresetPlaceholders } from "./templates"; -import { QuestionType as QuestionId } from "@formbricks/types/questions"; -export type QuestionType = { +import { TSurveyQuestionType as QuestionId } from "@formbricks/types/surveys"; + +import { replaceQuestionPresetPlaceholders } from "./templates"; + +export type TSurveyQuestionType = { id: string; label: string; description: string; @@ -19,11 +25,11 @@ export type QuestionType = { preset: any; }; -export const questionTypes: QuestionType[] = [ +export const questionTypes: TSurveyQuestionType[] = [ { id: QuestionId.OpenText, label: "Free text", - description: "A single line of text", + description: "Ask for a text-based answer", icon: ChatBubbleBottomCenterTextIcon, preset: { headline: "Who let the dogs out?", @@ -63,32 +69,30 @@ export const questionTypes: QuestionType[] = [ }, }, { - id: QuestionId.NPS, - label: "Net Promoter Score® (NPS)", - description: "Rate satisfaction on a 0-10 scale", - icon: PresentationChartBarIcon, + id: QuestionId.PictureSelection, + label: "Picture Selection", + description: "Ask respondents to select one or more pictures", + icon: PhotoIcon, preset: { - headline: "How likely are you to recommend {{productName}} to a friend or colleague?", - lowerLabel: "Not at all likely", - upperLabel: "Extremely likely", - }, - }, - { - id: QuestionId.CTA, - label: "Call-to-Action", - description: "Ask your users to perform an action", - icon: CursorArrowRippleIcon, - preset: { - headline: "You are one of our power users!", - buttonLabel: "Book interview", - buttonExternal: false, - dismissButtonLabel: "Skip", + headline: "Which is the cutest puppy?", + subheader: "You can also pick both.", + allowMulti: true, + choices: [ + { + id: createId(), + imageUrl: "https://formbricks-cdn.s3.eu-central-1.amazonaws.com/puppy-1-small.jpg", + }, + { + id: createId(), + imageUrl: "https://formbricks-cdn.s3.eu-central-1.amazonaws.com/puppy-2-small.jpg", + }, + ], }, }, { id: QuestionId.Rating, label: "Rating", - description: "Ask your users to rate something", + description: "Ask respondents for a rating", icon: StarIcon, preset: { headline: "How would you rate {{productName}}", @@ -100,9 +104,32 @@ export const questionTypes: QuestionType[] = [ }, }, { - id: "consent", + id: QuestionId.NPS, + label: "Net Promoter Score (NPS)", + description: "Rate satisfaction on a 0-10 scale", + icon: PresentationChartBarIcon, + preset: { + headline: "How likely are you to recommend {{productName}} to a friend or colleague?", + lowerLabel: "Not at all likely", + upperLabel: "Extremely likely", + }, + }, + { + id: QuestionId.CTA, + label: "Call-to-Action", + description: "Prompt respondents to perform an action", + icon: CursorArrowRippleIcon, + preset: { + headline: "You are one of our power users!", + buttonLabel: "Book interview", + buttonExternal: false, + dismissButtonLabel: "Skip", + }, + }, + { + id: QuestionId.Consent, label: "Consent", - description: "Ask your users to accept something", + description: "Ask respondents for consent", icon: CheckIcon, preset: { headline: "Terms and Conditions", @@ -110,6 +137,37 @@ export const questionTypes: QuestionType[] = [ dismissButtonLabel: "Skip", }, }, + { + id: QuestionId.Date, + label: "Date", + description: "Ask your users to select a date", + icon: CalendarDaysIcon, + preset: { + headline: "When is your birthday?", + format: "M-d-y", + }, + }, + { + id: QuestionId.FileUpload, + label: "File Upload", + description: "Allow respondents to upload a file", + icon: ArrowUpTrayIcon, + preset: { + headline: "File Upload", + allowMultipleFiles: false, + }, + }, + { + id: QuestionId.Cal, + label: "Schedule a meeting", + description: "Allow respondents to schedule a meet", + icon: PhoneIcon, + preset: { + headline: "Schedule a call with me", + buttonLabel: "Skip", + calUserName: "rick/get-rick-rolled", + }, + }, ]; export const universalQuestionPresets = { @@ -121,7 +179,7 @@ export const getQuestionDefaults = (id: string, product: any) => { return replaceQuestionPresetPlaceholders(questionType?.preset, product); }; -export const getQuestionTypeName = (id: string) => { +export const getTSurveyQuestionTypeName = (id: string) => { const questionType = questionTypes.find((questionType) => questionType.id === id); return questionType?.label; }; diff --git a/apps/web/app/lib/responseNotes/responsesNotes.ts b/apps/web/app/lib/responseNotes/responsesNotes.ts deleted file mode 100644 index b436b327b0..0000000000 --- a/apps/web/app/lib/responseNotes/responsesNotes.ts +++ /dev/null @@ -1,21 +0,0 @@ -export const addResponseNote = async ( - environmentId: string, - surveyId: string, - responseId: string, - text: string -) => { - try { - const res = await fetch( - `/api/v1/environments/${environmentId}/surveys/${surveyId}/responses/${responseId}/responsesNotes`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(text), - } - ); - return await res.json(); - } catch (error) { - console.error(error); - throw Error(`createResponseNote: unable to create responseNote: ${error.message}`); - } -}; diff --git a/apps/web/app/lib/responses/questionResponseMapping.ts b/apps/web/app/lib/responses/questionResponseMapping.ts index 0466434f07..6b84e9173b 100644 --- a/apps/web/app/lib/responses/questionResponseMapping.ts +++ b/apps/web/app/lib/responses/questionResponseMapping.ts @@ -1,8 +1,8 @@ -import { Question } from "@formbricks/types/questions"; -import { TResponse } from "@formbricks/types/v1/responses"; +import { TResponse } from "@formbricks/types/responses"; +import { TSurveyQuestion } from "@formbricks/types/surveys"; export const getQuestionResponseMapping = ( - survey: { questions: Question[] }, + survey: { questions: TSurveyQuestion[] }, response: TResponse ): { question: string; answer: string }[] => { const questionResponseMapping: { question: string; answer: string }[] = []; diff --git a/apps/web/app/lib/singleUseSurveys.ts b/apps/web/app/lib/singleUseSurveys.ts index dff1e89a94..e66f0bd68e 100644 --- a/apps/web/app/lib/singleUseSurveys.ts +++ b/apps/web/app/lib/singleUseSurveys.ts @@ -1,27 +1,34 @@ -import { FORMBRICKS_ENCRYPTION_KEY } from "@formbricks/lib/constants"; -import { decryptAES128, encryptAES128 } from "@formbricks/lib/crypto"; import cuid2 from "@paralleldrive/cuid2"; +import { decryptAES128, symmetricDecrypt, symmetricEncrypt } from "@formbricks/lib/crypto"; +import { env } from "@formbricks/lib/env.mjs"; + // generate encrypted single use id for the survey export const generateSurveySingleUseId = (isEncrypted: boolean): string => { const cuid = cuid2.createId(); if (!isEncrypted) { return cuid; } - if (!FORMBRICKS_ENCRYPTION_KEY) { - throw new Error("FORMBRICKS_ENCRYPTION_KEY is not defined"); - } - const encryptedCuid = encryptAES128(FORMBRICKS_ENCRYPTION_KEY, cuid); + + const encryptedCuid = symmetricEncrypt(cuid, env.ENCRYPTION_KEY); return encryptedCuid; }; // validate the survey single use id export const validateSurveySingleUseId = (surveySingleUseId: string): string | undefined => { - if (!FORMBRICKS_ENCRYPTION_KEY) { - throw new Error("FORMBRICKS_ENCRYPTION_KEY is not defined"); - } try { - const decryptedCuid = decryptAES128(FORMBRICKS_ENCRYPTION_KEY!, surveySingleUseId); + let decryptedCuid: string | null = null; + + if (surveySingleUseId.length === 64) { + if (!env.FORMBRICKS_ENCRYPTION_KEY) { + throw new Error("FORMBRICKS_ENCRYPTION_KEY is not defined"); + } + + decryptedCuid = decryptAES128(env.FORMBRICKS_ENCRYPTION_KEY!, surveySingleUseId); + } else { + decryptedCuid = symmetricDecrypt(surveySingleUseId, env.ENCRYPTION_KEY); + } + if (cuid2.isCuid(decryptedCuid)) { return decryptedCuid; } else { diff --git a/apps/web/app/lib/surveys/surveys.ts b/apps/web/app/lib/surveys/surveys.ts index 69c47ba867..98783982dc 100644 --- a/apps/web/app/lib/surveys/surveys.ts +++ b/apps/web/app/lib/surveys/surveys.ts @@ -7,40 +7,12 @@ import { QuestionOptions, } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox"; import { QuestionFilterOptions } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResponseFilter"; -import { QuestionType } from "@formbricks/types/questions"; -import { TResponse } from "@formbricks/types/v1/responses"; -import { TSurvey } from "@formbricks/types/v1/surveys"; -import { TTag } from "@formbricks/types/v1/tags"; import { isWithinInterval } from "date-fns"; -export const generateQuestionsAndAttributes = (survey: TSurvey, responses: TResponse[]) => { - let questionNames: string[] = []; - - if (survey?.questions) { - questionNames = survey.questions.map((question) => question.headline); - } - - const attributeMap: Record> = {}; - - if (responses) { - responses.forEach((response) => { - const { person } = response; - if (person !== null) { - const { id, attributes } = person; - Object.keys(attributes).forEach((attributeName) => { - if (!attributeMap.hasOwnProperty(attributeName)) { - attributeMap[attributeName] = {}; - } - attributeMap[attributeName][id] = attributes[attributeName]; - }); - } - }); - } - return { - questionNames, - attributeMap, - }; -}; +import { TResponse } from "@formbricks/types/responses"; +import { TSurveyQuestionType } from "@formbricks/types/surveys"; +import { TSurvey } from "@formbricks/types/surveys"; +import { TTag } from "@formbricks/types/tags"; const conditionOptions = { openText: ["is"], @@ -116,7 +88,10 @@ export const generateQuestionAndFilterOptions = ( questionOptions = [...questionOptions, { header: OptionsType.QUESTIONS, option: questionsOptions }]; survey.questions.forEach((q) => { if (Object.keys(conditionOptions).includes(q.type)) { - if (q.type === QuestionType.MultipleChoiceMulti || q.type === QuestionType.MultipleChoiceSingle) { + if ( + q.type === TSurveyQuestionType.MultipleChoiceMulti || + q.type === TSurveyQuestionType.MultipleChoiceSingle + ) { questionFilterOptions.push({ type: q.type, filterOptions: conditionOptions[q.type], @@ -173,6 +148,76 @@ export const generateQuestionAndFilterOptions = ( return { questionOptions: [...questionOptions], questionFilterOptions: [...questionFilterOptions] }; }; +export const generateQuestionAndFilterOptionsForResponseSharing = ( + survey: TSurvey, + responses: TResponse[] +): { + questionOptions: QuestionOptions[]; + questionFilterOptions: QuestionFilterOptions[]; +} => { + let questionOptions: any = []; + let questionFilterOptions: any = []; + + let questionsOptions: any = []; + + survey.questions.forEach((q) => { + if (Object.keys(conditionOptions).includes(q.type)) { + questionsOptions.push({ + label: q.headline, + questionType: q.type, + type: OptionsType.QUESTIONS, + id: q.id, + }); + } + }); + questionOptions = [...questionOptions, { header: OptionsType.QUESTIONS, option: questionsOptions }]; + survey.questions.forEach((q) => { + if (Object.keys(conditionOptions).includes(q.type)) { + if ( + q.type === TSurveyQuestionType.MultipleChoiceMulti || + q.type === TSurveyQuestionType.MultipleChoiceSingle + ) { + questionFilterOptions.push({ + type: q.type, + filterOptions: conditionOptions[q.type], + filterComboBoxOptions: q?.choices ? q?.choices?.map((c) => c?.label) : [""], + id: q.id, + }); + } else { + questionFilterOptions.push({ + type: q.type, + filterOptions: conditionOptions[q.type], + filterComboBoxOptions: filterOptions[q.type], + id: q.id, + }); + } + } + }); + + const attributes = getPersonAttributes(responses); + if (attributes) { + questionOptions = [ + ...questionOptions, + { + header: OptionsType.ATTRIBUTES, + option: Object.keys(attributes).map((a) => { + return { label: a, type: OptionsType.ATTRIBUTES, id: a }; + }), + }, + ]; + Object.keys(attributes).forEach((a) => { + questionFilterOptions.push({ + type: "Attributes", + filterOptions: conditionOptions.userAttributes, + filterComboBoxOptions: attributes[a], + id: a, + }); + }); + } + + return { questionOptions: [...questionOptions], questionFilterOptions: [...questionFilterOptions] }; +}; + // get the filtered responses export const getFilterResponses = ( responses: TResponse[], @@ -196,10 +241,10 @@ export const getFilterResponses = ( selectedFilter.filter.forEach((filter) => { if (filter.questionType?.type === "Questions") { switch (filter.questionType?.questionType) { - case QuestionType.Consent: + case TSurveyQuestionType.Consent: toBeFilterResponses = toBeFilterResponses.filter((response) => { const questionID = response.questions.find( - (q) => q?.type === QuestionType.Consent && q?.id === filter?.questionType?.id + (q) => q?.type === TSurveyQuestionType.Consent && q?.id === filter?.questionType?.id )?.id; if (filter?.filterType?.filterComboBoxValue) { if (questionID) { @@ -217,10 +262,10 @@ export const getFilterResponses = ( return true; }); break; - case QuestionType.OpenText: + case TSurveyQuestionType.OpenText: toBeFilterResponses = toBeFilterResponses.filter((response) => { const questionID = response.questions.find( - (q) => q?.type === QuestionType.OpenText && q?.id === filter?.questionType?.id + (q) => q?.type === TSurveyQuestionType.OpenText && q?.id === filter?.questionType?.id )?.id; if (filter?.filterType?.filterComboBoxValue) { if (questionID) { @@ -238,10 +283,10 @@ export const getFilterResponses = ( return true; }); break; - case QuestionType.CTA: + case TSurveyQuestionType.CTA: toBeFilterResponses = toBeFilterResponses.filter((response) => { const questionID = response.questions.find( - (q) => q?.type === QuestionType.CTA && q?.id === filter?.questionType?.id + (q) => q?.type === TSurveyQuestionType.CTA && q?.id === filter?.questionType?.id )?.id; if (filter?.filterType?.filterComboBoxValue) { if (questionID) { @@ -259,19 +304,19 @@ export const getFilterResponses = ( return true; }); break; - case QuestionType.MultipleChoiceMulti: + case TSurveyQuestionType.MultipleChoiceMulti: toBeFilterResponses = toBeFilterResponses.filter((response) => { const question = response.questions.find( - (q) => q?.type === QuestionType.MultipleChoiceMulti && q?.id === filter?.questionType?.id + (q) => q?.type === TSurveyQuestionType.MultipleChoiceMulti && q?.id === filter?.questionType?.id ); if (filter?.filterType?.filterComboBoxValue) { if (question) { const responseValue = response.data[question.id]; const filterValue = filter?.filterType?.filterComboBoxValue; if (Array.isArray(responseValue) && Array.isArray(filterValue) && filterValue.length > 0) { - //@ts-ignore + //@ts-expect-error const updatedResponseValue = question?.choices - ? //@ts-ignore + ? //@ts-expect-error matchAndUpdateArray([...question?.choices], [...responseValue]) : responseValue; if (filter?.filterType?.filterValue === "Includes all") { @@ -288,10 +333,11 @@ export const getFilterResponses = ( return true; }); break; - case QuestionType.MultipleChoiceSingle: + case TSurveyQuestionType.MultipleChoiceSingle: toBeFilterResponses = toBeFilterResponses.filter((response) => { const questionID = response.questions.find( - (q) => q?.type === QuestionType.MultipleChoiceSingle && q?.id === filter?.questionType?.id + (q) => + q?.type === TSurveyQuestionType.MultipleChoiceSingle && q?.id === filter?.questionType?.id )?.id; if (filter?.filterType?.filterComboBoxValue) { if (questionID) { @@ -312,10 +358,10 @@ export const getFilterResponses = ( return true; }); break; - case QuestionType.NPS: + case TSurveyQuestionType.NPS: toBeFilterResponses = toBeFilterResponses.filter((response) => { const questionID = response.questions.find( - (q) => q?.type === QuestionType.NPS && q?.id === filter?.questionType?.id + (q) => q?.type === TSurveyQuestionType.NPS && q?.id === filter?.questionType?.id )?.id; const responseValue = questionID ? response.data[questionID] : undefined; const filterValue = @@ -345,10 +391,10 @@ export const getFilterResponses = ( return true; }); break; - case QuestionType.Rating: + case TSurveyQuestionType.Rating: toBeFilterResponses = toBeFilterResponses.filter((response) => { const questionID = response.questions.find( - (q) => q?.type === QuestionType.Rating && q?.id === filter?.questionType?.id + (q) => q?.type === TSurveyQuestionType.Rating && q?.id === filter?.questionType?.id )?.id; const responseValue = questionID ? response.data[questionID] : undefined; const filterValue = @@ -422,7 +468,6 @@ export const getFilterResponses = ( // filtering the data according to the dates if (dateRange?.from !== undefined && dateRange?.to !== undefined) { - // @ts-ignore toBeFilterResponses = toBeFilterResponses.filter((r) => isWithinInterval(r.createdAt, { start: dateRange.from!, end: dateRange.to! }) ); diff --git a/apps/web/app/lib/templates.ts b/apps/web/app/lib/templates.ts index e634d826da..65561840f9 100644 --- a/apps/web/app/lib/templates.ts +++ b/apps/web/app/lib/templates.ts @@ -1,7 +1,7 @@ -import { Question } from "@/../../packages/types/questions"; -import { TTemplate } from "@formbricks/types/v1/templates"; +import { TSurveyQuestion } from "@formbricks/types/surveys"; +import { TTemplate } from "@formbricks/types/templates"; -export const replaceQuestionPresetPlaceholders = (question: Question, product) => { +export const replaceQuestionPresetPlaceholders = (question: TSurveyQuestion, product) => { if (!question) return; if (!product) return question; const newQuestion = JSON.parse(JSON.stringify(question)); diff --git a/apps/web/app/lib/users/users.ts b/apps/web/app/lib/users/users.ts index bdecc0985f..fe576735ce 100644 --- a/apps/web/app/lib/users/users.ts +++ b/apps/web/app/lib/users/users.ts @@ -1,4 +1,4 @@ -import { hashPassword } from "../auth"; +import { hashPassword } from "@formbricks/lib/auth/util"; export const createUser = async ( name: string, @@ -88,7 +88,7 @@ export const resetPassword = async (token: string, password: string): Promise => { +export const deleteUser = async (): Promise => { try { const res = await fetch("/api/v1/users/me/", { method: "DELETE", diff --git a/apps/web/app/lib/utils.ts b/apps/web/app/lib/utils.ts index 6dfa66a16a..28f2e7b528 100644 --- a/apps/web/app/lib/utils.ts +++ b/apps/web/app/lib/utils.ts @@ -1,38 +1,4 @@ -import { TInvite } from "@formbricks/types/v1/invites"; - -export function capitalizeFirstLetter(string: string | null = "") { - if (string === null) { - return ""; - } - return string.charAt(0).toUpperCase() + string.slice(1); -} - -// write a function that takes a string and truncates it to the specified length -export const truncate = (str: string, length: number) => { - if (!str) return ""; - if (str.length > length) { - return str.substring(0, length) + "..."; - } - return str; -}; - -// write a function that takes a string and truncates the middle of it so that the beginning and ending are always visible -export const truncateMiddle = (str: string, length: number) => { - if (!str) return ""; - if (str.length > length) { - const start = str.substring(0, length / 2); - const end = str.substring(str.length - length / 2, str.length); - return start + " ... " + end; - } - return str; -}; - -export const scrollToTop = () => { - window.scrollTo({ - top: 0, - behavior: "smooth", - }); -}; +import { TInvite } from "@formbricks/types/invites"; export function isLight(color) { let r, g, b; @@ -48,41 +14,6 @@ export function isLight(color) { return r * 0.299 + g * 0.587 + b * 0.114 > 128; } -const shuffle = (array: any[]) => { - for (let i = 0; i < array.length; i++) { - const j = Math.floor(Math.random() * (i + 1)); - [array[i], array[j]] = [array[j], array[i]]; - } -}; - -export const shuffleArray = (array: any[], shuffleOption: string | undefined) => { - const arrayCopy = [...array]; - const otherIndex = arrayCopy.findIndex((element) => element.id === "other"); - const otherElement = otherIndex !== -1 ? arrayCopy.splice(otherIndex, 1)[0] : null; - - if (shuffleOption === "all") { - shuffle(arrayCopy); - } else if (shuffleOption === "exceptLast") { - const lastElement = arrayCopy.pop(); - shuffle(arrayCopy); - arrayCopy.push(lastElement); - } - - if (otherElement) { - arrayCopy.push(otherElement); - } - - return arrayCopy; -}; - -export enum MEMBERSHIP_ROLES { - OWNER = "owner", - ADMIN = "admin", - EDITOR = "editor", - DEVELOPER = "developer", - VIEWER = "viewer", -} - export const isInviteExpired = (invite: TInvite) => { const now = new Date(); const expiresAt = new Date(invite.expiresAt); diff --git a/apps/web/app/middleware/bucket.ts b/apps/web/app/middleware/bucket.ts new file mode 100644 index 0000000000..d49f9376da --- /dev/null +++ b/apps/web/app/middleware/bucket.ts @@ -0,0 +1,26 @@ +import rateLimit from "@/app/middleware/rateLimit"; + +import { + CLIENT_SIDE_API_RATE_LIMIT, + LOGIN_RATE_LIMIT, + SHARE_RATE_LIMIT, + SIGNUP_RATE_LIMIT, +} from "@formbricks/lib/constants"; + +export const signUpLimiter = rateLimit({ + interval: SIGNUP_RATE_LIMIT.interval, + allowedPerInterval: SIGNUP_RATE_LIMIT.allowedPerInterval, +}); +export const loginLimiter = rateLimit({ + interval: LOGIN_RATE_LIMIT.interval, + allowedPerInterval: LOGIN_RATE_LIMIT.allowedPerInterval, +}); +export const clientSideApiEndpointsLimiter = rateLimit({ + interval: CLIENT_SIDE_API_RATE_LIMIT.interval, + allowedPerInterval: CLIENT_SIDE_API_RATE_LIMIT.allowedPerInterval, +}); + +export const shareUrlLimiter = rateLimit({ + interval: SHARE_RATE_LIMIT.interval, + allowedPerInterval: SHARE_RATE_LIMIT.allowedPerInterval, +}); diff --git a/apps/web/app/middleware/endpointValidator.ts b/apps/web/app/middleware/endpointValidator.ts new file mode 100644 index 0000000000..1515a9f8c9 --- /dev/null +++ b/apps/web/app/middleware/endpointValidator.ts @@ -0,0 +1,15 @@ +export const loginRoute = (url: string) => url === "/api/auth/callback/credentials"; + +export const signupRoute = (url: string) => url === "/api/v1/users"; + +export const clientSideApiRoute = (url: string): boolean => { + if (url.includes("/api/v1/js/actions")) return true; + if (url.includes("/api/v1/client/storage")) return true; + const regex = /^\/api\/v\d+\/client\//; + return regex.test(url); +}; + +export const shareUrlRoute = (url: string): boolean => { + const regex = /\/share\/[A-Za-z0-9]+\/(summary|responses)/; + return regex.test(url); +}; diff --git a/apps/web/app/(auth)/auth/rate-limit.ts b/apps/web/app/middleware/rateLimit.ts similarity index 86% rename from apps/web/app/(auth)/auth/rate-limit.ts rename to apps/web/app/middleware/rateLimit.ts index 61b2241937..d1e882de87 100644 --- a/apps/web/app/(auth)/auth/rate-limit.ts +++ b/apps/web/app/middleware/rateLimit.ts @@ -2,6 +2,7 @@ import { LRUCache } from "lru-cache"; type Options = { interval: number; + allowedPerInterval: number; }; export default function rateLimit(options: Options) { @@ -20,7 +21,7 @@ export default function rateLimit(options: Options) { tokenCount[0] += 1; const currentUsage = tokenCount[0]; - const isRateLimited = currentUsage >= 5; + const isRateLimited = currentUsage >= options.allowedPerInterval; return isRateLimited ? reject() : resolve(); }), }; diff --git a/apps/web/app/not-found.tsx b/apps/web/app/not-found.tsx index a641a0edbc..461178c8a6 100644 --- a/apps/web/app/not-found.tsx +++ b/apps/web/app/not-found.tsx @@ -1,3 +1,5 @@ +import Link from "next/link"; + import { Button } from "@formbricks/ui/Button"; export default function NotFound() { @@ -9,9 +11,9 @@ export default function NotFound() {

Sorry, we couldn’t find the page you’re looking for.

- + + +
); diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index 478eead7c6..839b4a093f 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -1,10 +1,12 @@ -import ClientLogout from "@formbricks/ui/ClientLogout"; -import { authOptions } from "@formbricks/lib/authOptions"; -import { getFirstEnvironmentByUserId } from "@formbricks/lib/environment/service"; import type { Session } from "next-auth"; import { getServerSession } from "next-auth"; import { redirect } from "next/navigation"; +import { authOptions } from "@formbricks/lib/authOptions"; +import { ONBOARDING_DISABLED } from "@formbricks/lib/constants"; +import { getFirstEnvironmentByUserId } from "@formbricks/lib/environment/service"; +import ClientLogout from "@formbricks/ui/ClientLogout"; + export default async function Home() { const session: Session | null = await getServerSession(authOptions); @@ -12,7 +14,7 @@ export default async function Home() { redirect("/auth/login"); } - if (session?.user && !session?.user?.onboardingCompleted) { + if (!ONBOARDING_DISABLED && session?.user && !session?.user?.onboardingCompleted) { return redirect(`/onboarding`); } diff --git a/apps/web/app/s/[surveyId]/actions.ts b/apps/web/app/s/[surveyId]/actions.ts index d12e234578..1e001e2f71 100644 --- a/apps/web/app/s/[surveyId]/actions.ts +++ b/apps/web/app/s/[surveyId]/actions.ts @@ -1,25 +1,17 @@ "use server"; -interface LinkSurveyEmailData { - surveyId: string; - email: string; - surveyData?: { - name?: string; - subheading?: string; - } | null; -} +import { TSurveyPinValidationResponseError } from "@/app/s/[surveyId]/types"; -interface ISurveyPinValidationResponse { +import { LinkSurveyEmailData, sendLinkSurveyToVerifiedEmail } from "@formbricks/lib/emails/emails"; +import { verifyTokenForLinkSurvey } from "@formbricks/lib/jwt"; +import { getSurvey } from "@formbricks/lib/survey/service"; +import { TSurvey } from "@formbricks/types/surveys"; + +interface TSurveyPinValidationResponse { error?: TSurveyPinValidationResponseError; survey?: TSurvey; } -import { TSurveyPinValidationResponseError } from "@/app/s/[surveyId]/types"; -import { sendLinkSurveyToVerifiedEmail } from "@/app/lib/email"; -import { verifyTokenForLinkSurvey } from "@formbricks/lib/jwt"; -import { getSurvey } from "@formbricks/lib/survey/service"; -import { TSurvey } from "@formbricks/types/v1/surveys"; - export async function sendLinkSurveyEmailAction(data: LinkSurveyEmailData) { if (!data.surveyData) { throw new Error("No survey data provided"); @@ -30,15 +22,15 @@ export async function verifyTokenAction(token: string, surveyId: string): Promis return await verifyTokenForLinkSurvey(token, surveyId); } -export async function validateSurveyPin( +export async function validateSurveyPinAction( surveyId: string, - pin: number -): Promise { + pin: string +): Promise { try { const survey = await getSurvey(surveyId); if (!survey) return { error: TSurveyPinValidationResponseError.NOT_FOUND }; - const originalPin = survey.pin; + const originalPin = survey.pin?.toString(); if (!originalPin) return { survey }; diff --git a/apps/web/app/s/[surveyId]/components/LegalFooter.tsx b/apps/web/app/s/[surveyId]/components/LegalFooter.tsx index dbe33b4815..e55d41065d 100644 --- a/apps/web/app/s/[surveyId]/components/LegalFooter.tsx +++ b/apps/web/app/s/[surveyId]/components/LegalFooter.tsx @@ -1,19 +1,29 @@ -import { IMPRINT_URL, PRIVACY_URL } from "@formbricks/lib/constants"; import Link from "next/link"; -export default function LegalFooter() { +interface LegalFooterProps { + bgColor?: string | null; + IMPRINT_URL?: string; + PRIVACY_URL?: string; +} + +export default function LegalFooter({ bgColor, IMPRINT_URL, PRIVACY_URL }: LegalFooterProps) { if (!IMPRINT_URL && !PRIVACY_URL) return null; + return ( -
-
+
+
{IMPRINT_URL && ( - + Imprint )} - {IMPRINT_URL && PRIVACY_URL && | } + {IMPRINT_URL && PRIVACY_URL && |} {PRIVACY_URL && ( - + Privacy Policy )} diff --git a/apps/web/app/s/[surveyId]/components/LinkSurvey.tsx b/apps/web/app/s/[surveyId]/components/LinkSurvey.tsx index 8cb8c07bfc..ceaf093eed 100644 --- a/apps/web/app/s/[surveyId]/components/LinkSurvey.tsx +++ b/apps/web/app/s/[surveyId]/components/LinkSurvey.tsx @@ -1,46 +1,51 @@ "use client"; -import ContentWrapper from "@formbricks/ui/ContentWrapper"; -import { SurveyInline } from "@formbricks/ui/Survey"; -import { createDisplay } from "@formbricks/lib/client/display"; -import { ResponseQueue } from "@formbricks/lib/responseQueue"; -import { SurveyState } from "@formbricks/lib/surveyState"; -import { TProduct } from "@formbricks/types/v1/product"; -import { TSurvey } from "@formbricks/types/v1/surveys"; +import SurveyLinkUsed from "@/app/s/[surveyId]/components/SurveyLinkUsed"; +import VerifyEmail from "@/app/s/[surveyId]/components/VerifyEmail"; +import { getPrefillResponseData } from "@/app/s/[surveyId]/lib/prefilling"; import { ArrowPathIcon } from "@heroicons/react/24/solid"; import { useSearchParams } from "next/navigation"; import { useEffect, useMemo, useState } from "react"; -import VerifyEmail from "@/app/s/[surveyId]/components/VerifyEmail"; -import { getPrefillResponseData } from "@/app/s/[surveyId]/lib/prefilling"; -import { TResponse, TResponseData, TResponseUpdate } from "@formbricks/types/v1/responses"; -import SurveyLinkUsed from "@/app/s/[surveyId]/components/SurveyLinkUsed"; + +import { FormbricksAPI } from "@formbricks/api"; +import { ResponseQueue } from "@formbricks/lib/responseQueue"; +import { SurveyState } from "@formbricks/lib/surveyState"; +import { TProduct } from "@formbricks/types/product"; +import { TResponse, TResponseData, TResponseUpdate } from "@formbricks/types/responses"; +import { TUploadFileConfig } from "@formbricks/types/storage"; +import { TSurvey } from "@formbricks/types/surveys"; +import ContentWrapper from "@formbricks/ui/ContentWrapper"; +import { SurveyInline } from "@formbricks/ui/Survey"; interface LinkSurveyProps { survey: TSurvey; product: TProduct; - personId?: string; + userId?: string; emailVerificationStatus?: string; prefillAnswer?: string; singleUseId?: string; singleUseResponse?: TResponse; webAppUrl: string; + responseCount?: number; } export default function LinkSurvey({ survey, product, - personId, + userId, emailVerificationStatus, prefillAnswer, singleUseId, singleUseResponse, webAppUrl, + responseCount, }: LinkSurveyProps) { const responseId = singleUseResponse?.id; const searchParams = useSearchParams(); const isPreview = searchParams?.get("preview") === "true"; + const sourceParam = searchParams?.get("source"); // pass in the responseId if the survey is a single use survey, ensures survey state is updated with the responseId - const [surveyState, setSurveyState] = useState(new SurveyState(survey.id, singleUseId, responseId)); + const [surveyState, setSurveyState] = useState(new SurveyState(survey.id, singleUseId, responseId, userId)); const [activeQuestionId, setActiveQuestionId] = useState( survey.welcomeCard.enabled ? "start" : survey?.questions[0]?.id ); @@ -55,16 +60,16 @@ export default function LinkSurvey({ new ResponseQueue( { apiHost: webAppUrl, + environmentId: survey.environmentId, retryAttempts: 2, onResponseSendingFailed: (response) => { alert(`Failed to send response: ${JSON.stringify(response, null, 2)}`); }, setSurveyState: setSurveyState, - personId, }, surveyState ), - [personId, webAppUrl] + [webAppUrl] ); const [autoFocus, setAutofocus] = useState(false); const hasFinishedSingleUseResponse = useMemo(() => { @@ -82,21 +87,20 @@ export default function LinkSurvey({ } }, []); - const [hiddenFieldsRecord, setHiddenFieldsRecord] = useState>(); + const hiddenFieldsRecord = useMemo | null>(() => { + const fieldsRecord: Record = {}; + let fieldsSet = false; - useEffect(() => { survey.hiddenFields?.fieldIds?.forEach((field) => { - // set the question and answer to the survey state const answer = searchParams?.get(field); if (answer) { - setHiddenFieldsRecord((prev) => { - return { - ...prev, - [field]: answer, - }; - }); + fieldsRecord[field] = answer; + fieldsSet = true; } }); + + // Only return the record if at least one field was set. + return fieldsSet ? fieldsRecord : null; }, [searchParams, survey.hiddenFields?.fieldIds]); useEffect(() => { @@ -106,8 +110,7 @@ export default function LinkSurvey({ if (!surveyState.isResponseFinished() && hasFinishedSingleUseResponse) { return ; } - - if (emailVerificationStatus && emailVerificationStatus !== "verified") { + if (survey.verifyEmail && emailVerificationStatus !== "verified") { if (emailVerificationStatus === "fishy") { return ; } @@ -117,7 +120,7 @@ export default function LinkSurvey({ return ( <> - + {isPreview && (
@@ -134,10 +137,21 @@ export default function LinkSurvey({ { if (!isPreview) { - const { id } = await createDisplay({ surveyId: survey.id }, webAppUrl); + const api = new FormbricksAPI({ + apiHost: webAppUrl, + environmentId: survey.environmentId, + }); + const res = await api.client.display.create({ + surveyId: survey.id, + }); + if (!res.ok) { + throw new Error("Could not create display"); + } + const { id } = res.data; + const newSurveyState = surveyState.copy(); newSurveyState.updateDisplayId(id); setSurveyState(newSurveyState); @@ -150,13 +164,33 @@ export default function LinkSurvey({ ...responseUpdate.data, ...hiddenFieldsRecord, }, + ttc: responseUpdate.ttc, finished: responseUpdate.finished, + meta: { + url: window.location.href, + source: sourceParam || "", + }, }); }} + onFileUpload={async (file: File, params: TUploadFileConfig) => { + const api = new FormbricksAPI({ + apiHost: webAppUrl, + environmentId: survey.environmentId, + }); + + try { + const uploadedUrl = await api.client.storage.uploadFile(file, params); + return uploadedUrl; + } catch (err) { + console.error(err); + return ""; + } + }} onActiveQuestionChange={(questionId) => setActiveQuestionId(questionId)} activeQuestionId={activeQuestionId} autoFocus={autoFocus} prefillResponseData={prefillResponseData} + responseCount={responseCount} /> diff --git a/apps/web/app/s/[surveyId]/components/MediaBackground.tsx b/apps/web/app/s/[surveyId]/components/MediaBackground.tsx new file mode 100644 index 0000000000..8e9390d489 --- /dev/null +++ b/apps/web/app/s/[surveyId]/components/MediaBackground.tsx @@ -0,0 +1,97 @@ +"use client"; + +import React from "react"; + +import { TSurvey } from "@formbricks/types/surveys"; + +interface MediaBackgroundProps { + children: React.ReactNode; + survey: TSurvey; + isEditorView?: boolean; + isMobilePreview?: boolean; + ContentRef?: React.RefObject; +} + +export const MediaBackground: React.FC = ({ + children, + survey, + isEditorView = false, + isMobilePreview = false, + ContentRef, +}) => { + const getFilterStyle = () => { + return survey.styling?.background?.brightness + ? `brightness(${survey.styling?.background?.brightness}%)` + : "brightness(100%)"; + }; + + const renderBackground = () => { + const filterStyle = getFilterStyle(); + const baseClasses = "absolute inset-0 h-full w-full"; + + switch (survey.styling?.background?.bgType) { + case "color": + return ( +
+ ); + case "animation": + return ( + + ); + case "image": + return ( +
+ ); + default: + return
; + } + }; + + const renderContent = () => ( +
+ {children} +
+ ); + + if (isMobilePreview) { + return ( +
+ {/* below element is use to create notch for the mobile device mockup */} +
+ {renderBackground()} + {renderContent()} +
+ ); + } else if (isEditorView) { + return ( +
+
+ {renderBackground()} +
{children}
+
+
+ ); + } else { + return ( +
+ {renderBackground()} +
{children}
+
+ ); + } +}; diff --git a/apps/web/app/s/[surveyId]/components/PinScreen.tsx b/apps/web/app/s/[surveyId]/components/PinScreen.tsx index dc08248e36..a0df45a022 100644 --- a/apps/web/app/s/[surveyId]/components/PinScreen.tsx +++ b/apps/web/app/s/[surveyId]/components/PinScreen.tsx @@ -1,25 +1,30 @@ "use client"; -import type { NextPage } from "next"; -import { TProduct } from "@/../../packages/types/v1/product"; -import { TResponse } from "@/../../packages/types/v1/responses"; -import { OTPInput } from "@formbricks/ui/OTPInput"; -import { useCallback, useEffect, useState } from "react"; -import { validateSurveyPin } from "@/app/s/[surveyId]/actions"; -import { TSurvey } from "@/../../packages/types/v1/surveys"; -import { TSurveyPinValidationResponseError } from "@/app/s/[surveyId]/types"; +import { validateSurveyPinAction } from "@/app/s/[surveyId]/actions"; +import LegalFooter from "@/app/s/[surveyId]/components/LegalFooter"; import LinkSurvey from "@/app/s/[surveyId]/components/LinkSurvey"; +import { MediaBackground } from "@/app/s/[surveyId]/components/MediaBackground"; +import { TSurveyPinValidationResponseError } from "@/app/s/[surveyId]/types"; +import type { NextPage } from "next"; +import { useCallback, useEffect, useState } from "react"; + import { cn } from "@formbricks/lib/cn"; +import { TProduct } from "@formbricks/types/product"; +import { TResponse } from "@formbricks/types/responses"; +import { TSurvey } from "@formbricks/types/surveys"; +import { OTPInput } from "@formbricks/ui/OTPInput"; interface LinkSurveyPinScreenProps { surveyId: string; product: TProduct; - personId?: string; + userId?: string; emailVerificationStatus?: string; prefillAnswer?: string; singleUseId?: string; singleUseResponse?: TResponse; webAppUrl: string; + IMPRINT_URL?: string; + PRIVACY_URL?: string; } const LinkSurveyPinScreen: NextPage = (props) => { @@ -28,10 +33,12 @@ const LinkSurveyPinScreen: NextPage = (props) => { product, webAppUrl, emailVerificationStatus, - personId, + userId, prefillAnswer, singleUseId, singleUseResponse, + IMPRINT_URL, + PRIVACY_URL, } = props; const [localPinEntry, setLocalPinEntry] = useState(""); @@ -40,8 +47,8 @@ const LinkSurveyPinScreen: NextPage = (props) => { const [error, setError] = useState(); const [survey, setSurvey] = useState(); - const _validateSurveyPinAsync = useCallback(async (surveyId: string, pin: number) => { - const response = await validateSurveyPin(surveyId, pin); + const _validateSurveyPinAsync = useCallback(async (surveyId: string, pin: string) => { + const response = await validateSurveyPinAction(surveyId, pin); if (response.error) { setError(response.error); } else if (response.survey) { @@ -69,12 +76,10 @@ const LinkSurveyPinScreen: NextPage = (props) => { const validPinRegex = /^\d{4}$/; const isValidPin = validPinRegex.test(localPinEntry); - const pinAsNumber = Number(localPinEntry); - if (isValidPin) { // Show loading and check against the server setLoading(true); - _validateSurveyPinAsync(surveyId, pinAsNumber); + _validateSurveyPinAsync(surveyId, localPinEntry); return; } @@ -102,16 +107,25 @@ const LinkSurveyPinScreen: NextPage = (props) => { } return ( - +
+ + + + +
); }; diff --git a/apps/web/app/s/[surveyId]/components/SurveyInactive.tsx b/apps/web/app/s/[surveyId]/components/SurveyInactive.tsx index 139eb109c6..ac3bdcf44c 100644 --- a/apps/web/app/s/[surveyId]/components/SurveyInactive.tsx +++ b/apps/web/app/s/[surveyId]/components/SurveyInactive.tsx @@ -1,8 +1,10 @@ -import { TSurveyClosedMessage } from "@formbricks/types/v1/surveys"; -import { Button } from "@formbricks/ui/Button"; import { CheckCircleIcon, PauseCircleIcon, QuestionMarkCircleIcon } from "@heroicons/react/24/solid"; import Image from "next/image"; import Link from "next/link"; + +import { TSurveyClosedMessage } from "@formbricks/types/surveys"; +import { Button } from "@formbricks/ui/Button"; + import footerLogo from "../lib/footerlogo.svg"; const SurveyInactive = ({ diff --git a/apps/web/app/s/[surveyId]/components/SurveyLinkUsed.tsx b/apps/web/app/s/[surveyId]/components/SurveyLinkUsed.tsx index d5bdf010e8..27bed3650f 100644 --- a/apps/web/app/s/[surveyId]/components/SurveyLinkUsed.tsx +++ b/apps/web/app/s/[surveyId]/components/SurveyLinkUsed.tsx @@ -1,11 +1,13 @@ -import { SurveySingleUse } from "@formbricks/types/surveys"; import { CheckCircleIcon } from "@heroicons/react/24/solid"; import Image from "next/image"; import Link from "next/link"; + +import { TSurveySingleUse } from "@formbricks/types/surveys"; + import footerLogo from "../lib/footerlogo.svg"; type SurveyLinkUsedProps = { - singleUseMessage: Omit | null; + singleUseMessage: TSurveySingleUse | null; }; const SurveyLinkUsed = ({ singleUseMessage }: SurveyLinkUsedProps) => { diff --git a/apps/web/app/s/[surveyId]/components/VerifyEmail.tsx b/apps/web/app/s/[surveyId]/components/VerifyEmail.tsx index d8f963224d..d32e86cae3 100644 --- a/apps/web/app/s/[surveyId]/components/VerifyEmail.tsx +++ b/apps/web/app/s/[surveyId]/components/VerifyEmail.tsx @@ -1,12 +1,13 @@ "use client"; -import React, { useState } from "react"; +import { sendLinkSurveyEmailAction } from "@/app/s/[surveyId]/actions"; import { EnvelopeIcon } from "@heroicons/react/24/solid"; +import { useState } from "react"; +import { Toaster, toast } from "react-hot-toast"; + +import { TSurvey } from "@formbricks/types/surveys"; import { Button } from "@formbricks/ui/Button"; import { Input } from "@formbricks/ui/Input"; -import { Toaster, toast } from "react-hot-toast"; -import { sendLinkSurveyEmailAction } from "@/app/s/[surveyId]/actions"; -import { TSurvey } from "@formbricks/types/v1/surveys"; export default function VerifyEmail({ survey, @@ -52,6 +53,12 @@ export default function VerifyEmail({ setEmailSent(false); }; + const handleKeyPress = (e) => { + if (e.key === "Enter") { + submitEmail(email); + } + }; + if (isErrorComponent) { return (
@@ -65,27 +72,30 @@ export default function VerifyEmail({ } return ( -
+
{!emailSent && !showPreviewQuestions && ( -
+
-

Verify your email to respond.

-

To respond to this survey please verify your email.

+

Verify your email to respond.

+

+ To respond to this survey please verify your email. +

setEmail(e.target.value)} + onKeyPress={handleKeyPress} />
-

- Just curious? Preview survey questions. +

+ Just curious? Preview survey questions.

)} @@ -97,15 +107,15 @@ export default function VerifyEmail({

{`${index + 1}. ${question.headline}`}

))}
-

- Want to respond? Verify email. +

+ Want to respond? Verify email.

)} {emailSent && ( -
-

Survey sent successfully

-

+

+

Check your email.

+

We sent an email to {email}. Please click the link in the email to take your survey.

diff --git a/apps/web/app/s/[surveyId]/layout.tsx b/apps/web/app/s/[surveyId]/layout.tsx index ccb2966cb8..0d2c86aebb 100644 --- a/apps/web/app/s/[surveyId]/layout.tsx +++ b/apps/web/app/s/[surveyId]/layout.tsx @@ -1,10 +1,3 @@ -import LegalFooter from "@/app/s/[surveyId]/components/LegalFooter"; - -export default function SurveyLayout({ children }) { - return ( -
-
{children}
- -
- ); +export default async function SurveyLayout({ children }) { + return
{children}
; } diff --git a/apps/web/app/s/[surveyId]/lib/prefilling.ts b/apps/web/app/s/[surveyId]/lib/prefilling.ts index a77f872cdf..a5c548abe7 100644 --- a/apps/web/app/s/[surveyId]/lib/prefilling.ts +++ b/apps/web/app/s/[surveyId]/lib/prefilling.ts @@ -1,6 +1,6 @@ -import { QuestionType } from "@formbricks/types/questions"; -import { TResponseData } from "@formbricks/types/v1/responses"; -import { TSurvey, TSurveyQuestion } from "@formbricks/types/v1/surveys"; +import { TResponseData } from "@formbricks/types/responses"; +import { TSurveyQuestionType } from "@formbricks/types/surveys"; +import { TSurvey, TSurveyQuestion } from "@formbricks/types/surveys"; export function getPrefillResponseData( currentQuestion: TSurveyQuestion, @@ -19,7 +19,7 @@ export function getPrefillResponseData( const answerObj = { [firstQuestionId]: answer }; if ( - question.type === QuestionType.CTA && + question.type === TSurveyQuestionType.CTA && question.buttonExternal && question.buttonUrl && answer === "clicked" @@ -39,10 +39,10 @@ export const checkValidity = (question: TSurveyQuestion, answer: any): boolean = if (question.required && (!answer || answer === "")) return false; try { switch (question.type) { - case QuestionType.OpenText: { + case TSurveyQuestionType.OpenText: { return true; } - case QuestionType.MultipleChoiceSingle: { + case TSurveyQuestionType.MultipleChoiceSingle: { const hasOther = question.choices[question.choices.length - 1].id === "other"; if (!hasOther) { if (!question.choices.find((choice) => choice.label === answer)) return false; @@ -50,7 +50,7 @@ export const checkValidity = (question: TSurveyQuestion, answer: any): boolean = } return true; } - case QuestionType.MultipleChoiceMulti: { + case TSurveyQuestionType.MultipleChoiceMulti: { answer = answer.split(","); const hasOther = question.choices[question.choices.length - 1].id === "other"; if (!hasOther) { @@ -60,7 +60,7 @@ export const checkValidity = (question: TSurveyQuestion, answer: any): boolean = } return true; } - case QuestionType.NPS: { + case TSurveyQuestionType.NPS: { answer = answer.replace(/&/g, ";"); const answerNumber = Number(JSON.parse(answer)); @@ -68,22 +68,28 @@ export const checkValidity = (question: TSurveyQuestion, answer: any): boolean = if (answerNumber < 0 || answerNumber > 10) return false; return true; } - case QuestionType.CTA: { + case TSurveyQuestionType.CTA: { if (question.required && answer === "dismissed") return false; if (answer !== "clicked" && answer !== "dismissed") return false; return true; } - case QuestionType.Consent: { + case TSurveyQuestionType.Consent: { if (question.required && answer === "dismissed") return false; if (answer !== "accepted" && answer !== "dismissed") return false; return true; } - case QuestionType.Rating: { + case TSurveyQuestionType.Rating: { answer = answer.replace(/&/g, ";"); const answerNumber = Number(JSON.parse(answer)); if (answerNumber < 1 || answerNumber > question.range) return false; return true; } + case TSurveyQuestionType.PictureSelection: { + answer = answer.split(","); + if (!answer.every((ans: string) => question.choices.find((choice) => choice.id === ans))) + return false; + return true; + } default: return false; } @@ -94,20 +100,24 @@ export const checkValidity = (question: TSurveyQuestion, answer: any): boolean = export const transformAnswer = (question: TSurveyQuestion, answer: string): string | number | string[] => { switch (question.type) { - case QuestionType.OpenText: - case QuestionType.MultipleChoiceSingle: - case QuestionType.Consent: - case QuestionType.CTA: { + case TSurveyQuestionType.OpenText: + case TSurveyQuestionType.MultipleChoiceSingle: + case TSurveyQuestionType.Consent: + case TSurveyQuestionType.CTA: { return answer; } - case QuestionType.Rating: - case QuestionType.NPS: { + case TSurveyQuestionType.Rating: + case TSurveyQuestionType.NPS: { answer = answer.replace(/&/g, ";"); return Number(JSON.parse(answer)); } - case QuestionType.MultipleChoiceMulti: { + case TSurveyQuestionType.PictureSelection: { + return answer.split(","); + } + + case TSurveyQuestionType.MultipleChoiceMulti: { let ansArr = answer.split(","); const hasOthers = question.choices[question.choices.length - 1].id === "other"; if (!hasOthers) return ansArr; diff --git a/apps/web/app/s/[surveyId]/not-found.tsx b/apps/web/app/s/[surveyId]/not-found.tsx index 2d83bdb6a1..1738ef32bb 100644 --- a/apps/web/app/s/[surveyId]/not-found.tsx +++ b/apps/web/app/s/[surveyId]/not-found.tsx @@ -1,9 +1,11 @@ -import React from "react"; import { QuestionMarkCircleIcon } from "@heroicons/react/24/solid"; -import footerLogo from "./lib/footerlogo.svg"; import Image from "next/image"; -import { Button } from "@formbricks/ui/Button"; import Link from "next/link"; +import React from "react"; + +import { Button } from "@formbricks/ui/Button"; + +import footerLogo from "./lib/footerlogo.svg"; export default function NotFound() { return ( diff --git a/apps/web/app/s/[surveyId]/page.tsx b/apps/web/app/s/[surveyId]/page.tsx index c7cc1dfbd4..cb65a102a1 100644 --- a/apps/web/app/s/[surveyId]/page.tsx +++ b/apps/web/app/s/[surveyId]/page.tsx @@ -1,18 +1,24 @@ -export const revalidate = REVALIDATION_INTERVAL; - -import LinkSurvey from "@/app/s/[surveyId]/components/LinkSurvey"; -import SurveyInactive from "@/app/s/[surveyId]/components/SurveyInactive"; -import { REVALIDATION_INTERVAL, WEBAPP_URL } from "@formbricks/lib/constants"; -import { getOrCreatePersonByUserId } from "@formbricks/lib/person/service"; -import { getProductByEnvironmentId } from "@formbricks/lib/product/service"; -import { getSurvey } from "@formbricks/lib/survey/service"; -import { getEmailVerificationStatus } from "./lib/helpers"; -import { checkValidity } from "@/app/s/[surveyId]/lib/prefilling"; -import { notFound } from "next/navigation"; -import { getResponseBySingleUseId } from "@formbricks/lib/response/service"; -import { TResponse } from "@formbricks/types/v1/responses"; import { validateSurveySingleUseId } from "@/app/lib/singleUseSurveys"; +import LegalFooter from "@/app/s/[surveyId]/components/LegalFooter"; +import LinkSurvey from "@/app/s/[surveyId]/components/LinkSurvey"; +import { MediaBackground } from "@/app/s/[surveyId]/components/MediaBackground"; import PinScreen from "@/app/s/[surveyId]/components/PinScreen"; +import SurveyInactive from "@/app/s/[surveyId]/components/SurveyInactive"; +import { checkValidity } from "@/app/s/[surveyId]/lib/prefilling"; +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; + +import { IMPRINT_URL, PRIVACY_URL } from "@formbricks/lib/constants"; +import { WEBAPP_URL } from "@formbricks/lib/constants"; +import { createPerson, getPersonByUserId } from "@formbricks/lib/person/service"; +import { getProductByEnvironmentId } from "@formbricks/lib/product/service"; +import { getResponseBySingleUseId } from "@formbricks/lib/response/service"; +import { getResponseCountBySurveyId } from "@formbricks/lib/response/service"; +import { getSurvey } from "@formbricks/lib/survey/service"; +import { ZId } from "@formbricks/types/environment"; +import { TResponse } from "@formbricks/types/responses"; + +import { getEmailVerificationStatus } from "./lib/helpers"; interface LinkSurveyPageProps { params: { @@ -25,8 +31,65 @@ interface LinkSurveyPageProps { }; } -export default async function LinkSurveyPage({ params, searchParams }: LinkSurveyPageProps) { +export async function generateMetadata({ params }: LinkSurveyPageProps): Promise { + const validId = ZId.safeParse(params.surveyId); + if (!validId.success) { + notFound(); + } + const survey = await getSurvey(params.surveyId); + + if (!survey || survey.type !== "link" || survey.status === "draft") { + notFound(); + } + + const product = await getProductByEnvironmentId(survey.environmentId); + + if (!product) { + throw new Error("Product not found"); + } + + function getNameForURL(string) { + return string.replace(/ /g, "%20"); + } + + function getBrandColorForURL(string) { + return string.replace(/#/g, "%23"); + } + + const brandColor = getBrandColorForURL(product.brandColor); + const surveyName = getNameForURL(survey.name); + + const ogImgURL = `/api/v1/og?brandColor=${brandColor}&name=${surveyName}`; + + return { + title: survey.name, + metadataBase: new URL(WEBAPP_URL), + openGraph: { + title: survey.name, + description: "Create your own survey like this with Formbricks' open source survey suite.", + url: `/s/${survey.id}`, + siteName: "", + images: [ogImgURL], + locale: "en_US", + type: "website", + }, + twitter: { + card: "summary_large_image", + title: survey.name, + description: "Create your own survey like this with Formbricks' open source survey suite.", + images: [ogImgURL], + }, + }; +} + +export default async function LinkSurveyPage({ params, searchParams }: LinkSurveyPageProps) { + const validId = ZId.safeParse(params.surveyId); + if (!validId.success) { + notFound(); + } + const survey = await getSurvey(params.surveyId); + const suId = searchParams.suId; const isSingleUseSurvey = survey?.singleUse?.enabled; const isSingleUseSurveyEncrypted = survey?.singleUse?.isEncrypted; @@ -98,38 +161,53 @@ export default async function LinkSurveyPage({ params, searchParams }: LinkSurve } const userId = searchParams.userId; - let person; if (userId) { - person = await getOrCreatePersonByUserId(userId, survey.environmentId); + // make sure the person exists or get's created + const person = await getPersonByUserId(survey.environmentId, userId); + if (!person) { + await createPerson(survey.environmentId, userId); + } } const isSurveyPinProtected = Boolean(!!survey && survey.pin); - + const responseCount = await getResponseCountBySurveyId(survey.id); if (isSurveyPinProtected) { return ( ); } - return ( - - ); + return survey ? ( +
+ + + + +
+ ) : null; } diff --git a/apps/web/app/storage/[environmentId]/[accessType]/[fileName]/lib/deleteFile.ts b/apps/web/app/storage/[environmentId]/[accessType]/[fileName]/lib/deleteFile.ts new file mode 100644 index 0000000000..31438dca76 --- /dev/null +++ b/apps/web/app/storage/[environmentId]/[accessType]/[fileName]/lib/deleteFile.ts @@ -0,0 +1,25 @@ +import { responses } from "@/app/lib/api/response"; + +import { storageCache } from "@formbricks/lib/storage/cache"; +import { deleteFile } from "@formbricks/lib/storage/service"; +import { TAccessType } from "@formbricks/types/storage"; + +export const handleDeleteFile = async (environmentId: string, accessType: TAccessType, fileName: string) => { + try { + const { message, success, code } = await deleteFile(environmentId, accessType, fileName); + + if (success) { + // revalidate cache + storageCache.revalidate({ fileKey: `${environmentId}/${accessType}/${fileName}` }); + return responses.successResponse(message); + } + + if (code === 404) { + return responses.notFoundResponse("File", "File not found"); + } + + return responses.internalServerErrorResponse(message); + } catch (err) { + return responses.internalServerErrorResponse("Something went wrong"); + } +}; diff --git a/apps/web/app/storage/[environmentId]/[accessType]/[fileName]/lib/getFile.ts b/apps/web/app/storage/[environmentId]/[accessType]/[fileName]/lib/getFile.ts new file mode 100644 index 0000000000..4d3be3096a --- /dev/null +++ b/apps/web/app/storage/[environmentId]/[accessType]/[fileName]/lib/getFile.ts @@ -0,0 +1,45 @@ +import { responses } from "@/app/lib/api/response"; +import { notFound } from "next/navigation"; +import path from "path"; + +import { UPLOADS_DIR } from "@formbricks/lib/constants"; +import { env } from "@formbricks/lib/env.mjs"; +import { getLocalFile, getS3File } from "@formbricks/lib/storage/service"; + +const getFile = async (environmentId: string, accessType: string, fileName: string) => { + if (!env.S3_ACCESS_KEY || !env.S3_SECRET_KEY || !env.S3_REGION || !env.S3_BUCKET_NAME) { + try { + const { fileBuffer, metaData } = await getLocalFile( + path.join(UPLOADS_DIR, environmentId, accessType, fileName) + ); + + return new Response(fileBuffer, { + headers: { + "Content-Type": metaData.contentType, + "Content-Disposition": "attachment", + }, + }); + } catch (err) { + notFound(); + } + } + + try { + const signedUrl = await getS3File(`${environmentId}/${accessType}/${fileName}`); + + return new Response(null, { + status: 302, + headers: { + Location: signedUrl, + }, + }); + } catch (err) { + if (err.name === "NoSuchKey") { + return responses.notFoundResponse("File not found", fileName); + } else { + return responses.internalServerErrorResponse("Internal server error"); + } + } +}; + +export default getFile; diff --git a/apps/web/app/storage/[environmentId]/[accessType]/[fileName]/route.ts b/apps/web/app/storage/[environmentId]/[accessType]/[fileName]/route.ts index 169f0d6616..ee94a5b6a7 100644 --- a/apps/web/app/storage/[environmentId]/[accessType]/[fileName]/route.ts +++ b/apps/web/app/storage/[environmentId]/[accessType]/[fileName]/route.ts @@ -1,15 +1,14 @@ -import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions"; -import { env } from "@/env.mjs"; import { responses } from "@/app/lib/api/response"; import { transformErrorToDetails } from "@/app/lib/api/validator"; -import { UPLOADS_DIR } from "@formbricks/lib/constants"; -import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth"; -import { getFileFromLocalStorage, getFileFromS3 } from "@formbricks/lib/storage/service"; -import { ZStorageRetrievalParams } from "@formbricks/types/v1/storage"; +import { handleDeleteFile } from "@/app/storage/[environmentId]/[accessType]/[fileName]/lib/deleteFile"; import { getServerSession } from "next-auth"; -import { notFound } from "next/navigation"; import { NextRequest } from "next/server"; -import path from "path"; + +import { authOptions } from "@formbricks/lib/authOptions"; +import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth"; +import { ZStorageRetrievalParams } from "@formbricks/types/storage"; + +import getFile from "./lib/getFile"; export async function GET( _: NextRequest, @@ -25,46 +24,12 @@ export async function GET( ); } - const { environmentId, accessType, fileName } = params; + const { environmentId, accessType, fileName: fileNameOG } = params; - const getFile = async () => { - if (!env.S3_ACCESS_KEY || !env.S3_SECRET_KEY || !env.S3_REGION || !env.S3_BUCKET_NAME) { - try { - const { fileBuffer, metaData } = await getFileFromLocalStorage( - path.join(UPLOADS_DIR, environmentId, accessType, fileName) - ); - - return new Response(fileBuffer, { - headers: { - "Content-Type": metaData.contentType, - "Content-Disposition": "inline", - }, - }); - } catch (err) { - notFound(); - } - } - - try { - const { fileBuffer, metaData } = await getFileFromS3(`${environmentId}/${accessType}/${fileName}`); - - return new Response(fileBuffer, { - headers: { - "Content-Type": metaData.contentType, - "Content-Disposition": "inline", - }, - }); - } catch (err) { - if (err.name === "NoSuchKey") { - return responses.notFoundResponse("File not found", fileName); - } else { - return responses.internalServerErrorResponse("Internal server error"); - } - } - }; + const fileName = decodeURIComponent(fileNameOG); if (accessType === "public") { - return await getFile(); + return await getFile(environmentId, accessType, fileName); } // auth and download private file @@ -81,5 +46,47 @@ export async function GET( return responses.unauthorizedResponse(); } - return await getFile(); + const file = await getFile(environmentId, accessType, fileName); + return file; +} + +export async function DELETE(_: NextRequest, { params }: { params: { fileName: string } }) { + if (!params.fileName) { + return responses.badRequestResponse("Fields are missing or incorrectly formatted", { + fileName: "fileName is required", + }); + } + + const [environmentId, accessType, file] = params.fileName.split("/"); + + const paramValidation = ZStorageRetrievalParams.safeParse({ fileName: file, environmentId, accessType }); + + if (!paramValidation.success) { + return responses.badRequestResponse( + "Fields are missing or incorrectly formatted", + transformErrorToDetails(paramValidation.error), + true + ); + } + // check if user is authenticated + + const session = await getServerSession(authOptions); + + if (!session || !session.user) { + return responses.notAuthenticatedResponse(); + } + + // check if the user has access to the environment + + const isUserAuthorized = await hasUserEnvironmentAccess(session.user.id, environmentId); + + if (!isUserAuthorized) { + return responses.unauthorizedResponse(); + } + + return await handleDeleteFile( + paramValidation.data.environmentId, + paramValidation.data.accessType, + paramValidation.data.fileName + ); } diff --git a/apps/web/images/notion.png b/apps/web/images/notion.png new file mode 100644 index 0000000000..391051679c Binary files /dev/null and b/apps/web/images/notion.png differ diff --git a/apps/web/middleware.ts b/apps/web/middleware.ts index 267b6b8583..e3df726d6a 100644 --- a/apps/web/middleware.ts +++ b/apps/web/middleware.ts @@ -1,12 +1,20 @@ -import rateLimit from "@/app/(auth)/auth/rate-limit"; +import { + clientSideApiEndpointsLimiter, + loginLimiter, + shareUrlLimiter, + signUpLimiter, +} from "@/app/middleware/bucket"; +import { + clientSideApiRoute, + loginRoute, + shareUrlRoute, + signupRoute, +} from "@/app/middleware/endpointValidator"; import { NextResponse } from "next/server"; import type { NextRequest } from "next/server"; -const signUpLimiter = rateLimit({ interval: 60 * 60 * 1000 }); // 60 minutes -const loginLimiter = rateLimit({ interval: 15 * 60 * 1000 }); // 15 minutes - export async function middleware(request: NextRequest) { - if (process.env.IS_FORMBRICKS_CLOUD != "1") { + if (process.env.NODE_ENV !== "production") { return NextResponse.next(); } @@ -19,10 +27,14 @@ export async function middleware(request: NextRequest) { if (ip) { try { - if (request.nextUrl.pathname === "/api/auth/callback/credentials") { + if (loginRoute(request.nextUrl.pathname)) { await loginLimiter.check(ip); - } else if (request.nextUrl.pathname === "/api/v1/users") { + } else if (signupRoute(request.nextUrl.pathname)) { await signUpLimiter.check(ip); + } else if (clientSideApiRoute(request.nextUrl.pathname)) { + await clientSideApiEndpointsLimiter.check(ip); + } else if (shareUrlRoute(request.nextUrl.pathname)) { + await shareUrlLimiter.check(ip); } return res; } catch (_e) { @@ -30,11 +42,17 @@ export async function middleware(request: NextRequest) { return NextResponse.json({ error: "Too many requests, Please try after a while!" }, { status: 429 }); } - } else { - return NextResponse.json({ error: "Too many requests, Please try after a while!" }, { status: 429 }); } + return res; } export const config = { - matcher: ["/api/auth/callback/credentials", "/api/v1/users"], + matcher: [ + "/api/auth/callback/credentials", + "/api/v1/users", + "/api/(.*)/client/:path*", + "/api/v1/js/actions", + "/api/v1/client/storage", + "/share/(.*)/:path", + ], }; diff --git a/apps/web/next.config.mjs b/apps/web/next.config.mjs index 10a2f08a3b..5f3c4c0b0e 100644 --- a/apps/web/next.config.mjs +++ b/apps/web/next.config.mjs @@ -1,14 +1,20 @@ -import { withSentryConfig } from "@sentry/nextjs"; -import "./env.mjs"; import { createId } from "@paralleldrive/cuid2"; +import { withSentryConfig } from "@sentry/nextjs"; + +import "@formbricks/lib/env.mjs"; /** @type {import('next').NextConfig} */ +function getHostname(url) { + const urlObj = new URL(url); + return urlObj.hostname; +} + const nextConfig = { assetPrefix: process.env.ASSET_PREFIX_URL || undefined, output: "standalone", experimental: { - serverActions: true, + serverComponentsExternalPackages: ["@aws-sdk"], }, transpilePackages: ["@formbricks/database", "@formbricks/ee", "@formbricks/ui", "@formbricks/lib"], images: { @@ -29,18 +35,32 @@ const nextConfig = { protocol: "https", hostname: "app.formbricks.com", }, + { + protocol: "https", + hostname: "formbricks-cdn.s3.eu-central-1.amazonaws.com", + }, ], }, async redirects() { return [ + { + source: "/i/:path*", + destination: "/:path*", + permanent: false, + }, + { + source: "/api/v1/surveys", + destination: "/api/v1/management/surveys", + permanent: true, + }, { source: "/api/v1/responses", destination: "/api/v1/management/responses", permanent: true, }, { - source: "/api/v1/surveys", - destination: "/api/v1/management/surveys", + source: "/api/v1/me", + destination: "/api/v1/management/me", permanent: true, }, { @@ -88,6 +108,17 @@ const nextConfig = { }, }; +// set actions allowed origins +if (process.env.WEBAPP_URL) { + nextConfig.experimental.serverActions = { + allowedOrigins: [process.env.WEBAPP_URL.replace(/https?:\/\//, "")], + }; + nextConfig.images.remotePatterns.push({ + protocol: "https", + hostname: getHostname(process.env.WEBAPP_URL), + }); +} + const sentryOptions = { // For all available options, see: // https://github.com/getsentry/sentry-webpack-plugin#options diff --git a/apps/web/package.json b/apps/web/package.json index fa6a94099d..0a2e973133 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@formbricks/web", - "version": "1.1.0", + "version": "1.4.1", "private": true, "scripts": { "clean": "rimraf .turbo node_modules .next", @@ -20,46 +20,47 @@ "@formbricks/types": "workspace:*", "@formbricks/ui": "workspace:*", "@headlessui/react": "^1.7.17", - "@heroicons/react": "^2.0.18", - "@json2csv/node": "^7.0.3", + "@heroicons/react": "^2.1.1", + "@json2csv/node": "^7.0.4", "@paralleldrive/cuid2": "^2.2.2", "@radix-ui/react-collapsible": "^1.0.3", "@radix-ui/react-dropdown-menu": "^2.0.6", - "@react-email/components": "^0.0.7", - "@sentry/nextjs": "^7.73.0", - "@t3-oss/env-nextjs": "^0.7.1", + "@react-email/components": "^0.0.12", + "@sentry/nextjs": "^7.91.0", + "@vercel/og": "^0.6.2", "bcryptjs": "^2.4.3", + "dotenv": "^16.3.1", "encoding": "^0.1.13", - "framer-motion": "10.16.4", - "googleapis": "^127.0.0", + "framer-motion": "10.17.4", + "googleapis": "^129.0.0", "jsonwebtoken": "^9.0.2", "lodash": "^4.17.21", - "lru-cache": "^10.0.1", - "lucide-react": "^0.287.0", - "mime": "^3.0.0", - "next": "13.5.5", - "nodemailer": "^6.9.6", + "lru-cache": "^10.1.0", + "lucide-react": "^0.303.0", + "mime": "^4.0.1", + "next": "14.0.4", + "nodemailer": "^6.9.8", "otplib": "^12.0.1", - "posthog-js": "^1.83.1", + "posthog-js": "^1.96.1", "prismjs": "^1.29.0", "qrcode": "^1.5.3", "react": "18.2.0", "react-beautiful-dnd": "^13.1.1", "react-dom": "18.2.0", - "react-email": "^1.9.5", - "react-hook-form": "^7.47.0", + "react-email": "^1.10.0", + "react-hook-form": "^7.49.2", "react-hot-toast": "^2.4.1", - "react-icons": "^4.11.0", - "swr": "^2.2.4", - "ua-parser-js": "^1.0.36", + "react-icons": "^4.12.0", + "ua-parser-js": "^1.0.37", + "webpack": "^5.89.0", "xlsx": "^0.18.5" }, "devDependencies": { "@formbricks/tsconfig": "workspace:*", - "@types/bcryptjs": "^2.4.4", - "@types/lodash": "^4.14.199", - "@types/markdown-it": "^13.0.2", - "@types/qrcode": "^1.5.2", + "@types/bcryptjs": "^2.4.6", + "@types/lodash": "^4.14.202", + "@types/markdown-it": "^13.0.7", + "@types/qrcode": "^1.5.5", "eslint-config-formbricks": "workspace:*" } } diff --git a/apps/web/pages/api/billing/create-customer-portal-session.ts b/apps/web/pages/api/billing/create-customer-portal-session.ts deleted file mode 100644 index b241eed641..0000000000 --- a/apps/web/pages/api/billing/create-customer-portal-session.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from "@formbricks/ee/billing/api/create-customer-portal-session"; diff --git a/apps/web/pages/api/billing/stripe-webhook.ts b/apps/web/pages/api/billing/stripe-webhook.ts deleted file mode 100644 index 29e3471d53..0000000000 --- a/apps/web/pages/api/billing/stripe-webhook.ts +++ /dev/null @@ -1 +0,0 @@ -export { config, default } from "@formbricks/ee/billing/api/stripe-webhook"; diff --git a/apps/web/pages/api/v1/client/environments/[environmentId]/displays/[displayId]/responded.ts b/apps/web/pages/api/v1/client/environments/[environmentId]/displays/[displayId]/responded.ts index d741f160d1..e6cafdc69f 100644 --- a/apps/web/pages/api/v1/client/environments/[environmentId]/displays/[displayId]/responded.ts +++ b/apps/web/pages/api/v1/client/environments/[environmentId]/displays/[displayId]/responded.ts @@ -1,6 +1,7 @@ -import { prisma } from "@formbricks/database"; import type { NextApiRequest, NextApiResponse } from "next"; +import { prisma } from "@formbricks/database"; + export default async function handle(req: NextApiRequest, res: NextApiResponse) { const environmentId = req.query.environmentId?.toString(); diff --git a/apps/web/pages/api/v1/client/environments/[environmentId]/displays/index.ts b/apps/web/pages/api/v1/client/environments/[environmentId]/displays/index.ts index fa416371f6..b7242f417b 100644 --- a/apps/web/pages/api/v1/client/environments/[environmentId]/displays/index.ts +++ b/apps/web/pages/api/v1/client/environments/[environmentId]/displays/index.ts @@ -1,7 +1,8 @@ -import { capturePosthogEvent } from "@formbricks/lib/posthogServer"; -import { prisma } from "@formbricks/database"; import type { NextApiRequest, NextApiResponse } from "next"; +import { prisma } from "@formbricks/database"; +import { capturePosthogEvent } from "@formbricks/lib/posthogServer"; + export default async function handle(req: NextApiRequest, res: NextApiResponse) { const environmentId = req.query.environmentId?.toString(); diff --git a/apps/web/pages/api/v1/client/environments/[environmentId]/events/index.ts b/apps/web/pages/api/v1/client/environments/[environmentId]/events/index.ts index 8055dab5f0..e7d6faafd4 100644 --- a/apps/web/pages/api/v1/client/environments/[environmentId]/events/index.ts +++ b/apps/web/pages/api/v1/client/environments/[environmentId]/events/index.ts @@ -1,7 +1,8 @@ -import { prisma } from "@formbricks/database"; -import { TActionClassType } from "@formbricks/types/v1/actionClasses"; import type { NextApiRequest, NextApiResponse } from "next"; +import { prisma } from "@formbricks/database"; +import { TActionClassType } from "@formbricks/types/actionClasses"; + export default async function handle(req: NextApiRequest, res: NextApiResponse) { const environmentId = req.query.environmentId?.toString(); @@ -15,10 +16,10 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse) } // POST else if (req.method === "POST") { - const { sessionId, eventName, properties } = req.body; + const { personId, eventName, properties } = req.body; - if (!sessionId) { - return res.status(400).json({ message: "Missing sessionId" }); + if (!personId) { + return res.status(400).json({ message: "Missing personId" }); } if (!eventName) { return res.status(400).json({ message: "Missing eventName" }); @@ -29,15 +30,15 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse) eventType = "automatic"; } - const eventData = await prisma.event.create({ + const eventData = await prisma.action.create({ data: { properties, - session: { + person: { connect: { - id: sessionId, + id: personId, }, }, - eventClass: { + actionClass: { connectOrCreate: { where: { name_environmentId: { diff --git a/apps/web/pages/api/v1/client/environments/[environmentId]/people/[personId]/attribute.ts b/apps/web/pages/api/v1/client/environments/[environmentId]/people/[personId]/attribute.ts index 5a0a1ab1ec..eab8f25cf6 100644 --- a/apps/web/pages/api/v1/client/environments/[environmentId]/people/[personId]/attribute.ts +++ b/apps/web/pages/api/v1/client/environments/[environmentId]/people/[personId]/attribute.ts @@ -1,7 +1,9 @@ import { getSettings } from "@/app/lib/api/clientSettings"; -import { prisma } from "@formbricks/database"; import type { NextApiRequest, NextApiResponse } from "next"; +import { prisma } from "@formbricks/database"; +import { personCache } from "@formbricks/lib/person/cache"; + export default async function handle(req: NextApiRequest, res: NextApiResponse) { const environmentId = req.query.environmentId?.toString(); @@ -128,6 +130,11 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse) const person = attribute.person; + personCache.revalidate({ + id: person.id, + environmentId: person.environmentId, + }); + const settings = await getSettings(environmentId, person.id); // return updated person diff --git a/apps/web/pages/api/v1/client/environments/[environmentId]/people/[personId]/user-id.ts b/apps/web/pages/api/v1/client/environments/[environmentId]/people/[personId]/user-id.ts deleted file mode 100644 index ff9c962ee6..0000000000 --- a/apps/web/pages/api/v1/client/environments/[environmentId]/people/[personId]/user-id.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { getSettings } from "@/app/lib/api/clientSettings"; -import { prisma } from "@formbricks/database"; -import type { NextApiRequest, NextApiResponse } from "next"; - -export default async function handle(req: NextApiRequest, res: NextApiResponse) { - const environmentId = req.query.environmentId?.toString(); - - if (!environmentId) { - return res.status(400).json({ message: "Missing environmentId" }); - } - - const personId = req.query.personId?.toString(); - - if (!personId) { - return res.status(400).json({ message: "Missing personId" }); - } - - // CORS - if (req.method === "OPTIONS") { - res.status(200).end(); - } - // POST - else if (req.method === "POST") { - const { userId, sessionId } = req.body; - if (!userId) { - return res.status(400).json({ message: "Missing userId" }); - } - if (!sessionId) { - return res.status(400).json({ message: "Missing sessionId" }); - } - let person; - // check if person exists - const existingPerson = await prisma.person.findFirst({ - where: { - environmentId, - attributes: { - some: { - attributeClass: { - name: "userId", - }, - value: userId, - }, - }, - }, - select: { - id: true, - environmentId: true, - attributes: { - select: { - id: true, - value: true, - attributeClass: { - select: { - id: true, - name: true, - }, - }, - }, - }, - }, - }); - // if person exists, reconnect session and delete old user - if (existingPerson) { - // reconnect session to new person - await prisma.session.update({ - where: { - id: sessionId, - }, - data: { - person: { - connect: { - id: existingPerson.id, - }, - }, - }, - }); - - // delete old person - await prisma.person.delete({ - where: { - id: personId, - }, - }); - person = existingPerson; - } else { - // update person - person = await prisma.person.update({ - where: { - id: personId, - }, - data: { - attributes: { - create: { - value: userId, - attributeClass: { - connect: { - name_environmentId: { - name: "userId", - environmentId, - }, - }, - }, - }, - }, - }, - select: { - id: true, - environmentId: true, - attributes: { - select: { - id: true, - value: true, - attributeClass: { - select: { - id: true, - name: true, - }, - }, - }, - }, - }, - }); - } - - const settings = await getSettings(environmentId, person.id); - - // return updated person and settings - return res.json({ person, settings }); - } - - // Unknown HTTP Method - else { - throw new Error(`The HTTP ${req.method} method is not supported by this route.`); - } -} diff --git a/apps/web/pages/api/v1/client/environments/[environmentId]/people/index.ts b/apps/web/pages/api/v1/client/environments/[environmentId]/people/index.ts deleted file mode 100644 index b00bcef6f7..0000000000 --- a/apps/web/pages/api/v1/client/environments/[environmentId]/people/index.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { createPerson } from "@/app/lib/api/clientPerson"; -import { createSession } from "@/app/lib/api/clientSession"; -import { getSettings } from "@/app/lib/api/clientSettings"; -import type { NextApiRequest, NextApiResponse } from "next"; - -export default async function handle(req: NextApiRequest, res: NextApiResponse) { - const environmentId = req.query.environmentId?.toString(); - - if (!environmentId) { - return res.status(400).json({ message: "Missing environmentId" }); - } - - // CORS - if (req.method === "OPTIONS") { - res.status(200).end(); - } - // POST - else if (req.method === "POST") { - const person = await createPerson(environmentId); - const session = await createSession(person.id); - const settings = await getSettings(environmentId, person.id); - - return res.json({ person, session, settings }); - } - - // Unknown HTTP Method - else { - throw new Error(`The HTTP ${req.method} method is not supported by this route.`); - } -} diff --git a/apps/web/pages/api/v1/client/environments/[environmentId]/responses/[responseId]/index.ts b/apps/web/pages/api/v1/client/environments/[environmentId]/responses/[responseId]/index.ts index b91b2bc95b..f6cdd08de6 100644 --- a/apps/web/pages/api/v1/client/environments/[environmentId]/responses/[responseId]/index.ts +++ b/apps/web/pages/api/v1/client/environments/[environmentId]/responses/[responseId]/index.ts @@ -1,11 +1,13 @@ import { sendToPipeline } from "@/app/lib/pipelines"; +import type { NextApiRequest, NextApiResponse } from "next"; + import { prisma } from "@formbricks/database"; import { INTERNAL_SECRET, WEBAPP_URL } from "@formbricks/lib/constants"; -import { TPerson } from "@formbricks/types/v1/people"; -import { TPipelineInput } from "@formbricks/types/v1/pipelines"; -import { TResponse } from "@formbricks/types/v1/responses"; -import { TTag } from "@formbricks/types/v1/tags"; -import type { NextApiRequest, NextApiResponse } from "next"; +import { transformPrismaPerson } from "@formbricks/lib/person/service"; +import { responseCache } from "@formbricks/lib/response/cache"; +import { TPipelineInput } from "@formbricks/types/pipelines"; +import { TResponse } from "@formbricks/types/responses"; +import { TTag } from "@formbricks/types/tags"; export default async function handle(req: NextApiRequest, res: NextApiResponse) { const environmentId = req.query.environmentId?.toString(); @@ -62,12 +64,15 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse) surveyId: true, finished: true, data: true, + ttc: true, meta: true, personAttributes: true, singleUseId: true, person: { select: { id: true, + userId: true, + environmentId: true, createdAt: true, updatedAt: true, attributes: { @@ -114,20 +119,12 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse) }, }); - const transformPrismaPerson = (person): TPerson => { - const attributes = person.attributes.reduce((acc, attr) => { - acc[attr.attributeClass.name] = attr.value; - return acc; - }, {} as Record); - - return { - id: person.id, - attributes: attributes, - createdAt: person.createdAt, - updatedAt: person.updatedAt, - environmentId: environmentId, - }; - }; + // update response cache + responseCache.revalidate({ + id: responseId, + surveyId: responsePrisma.surveyId, + environmentId, + }); const responseData: TResponse = { ...responsePrisma, diff --git a/apps/web/pages/api/v1/client/environments/[environmentId]/responses/index.ts b/apps/web/pages/api/v1/client/environments/[environmentId]/responses/index.ts index 1264853c0e..c29853885f 100644 --- a/apps/web/pages/api/v1/client/environments/[environmentId]/responses/index.ts +++ b/apps/web/pages/api/v1/client/environments/[environmentId]/responses/index.ts @@ -1,11 +1,12 @@ import { sendToPipeline } from "@/app/lib/pipelines"; +import type { NextApiRequest, NextApiResponse } from "next"; + import { prisma } from "@formbricks/database"; +import { transformPrismaPerson } from "@formbricks/lib/person/service"; import { capturePosthogEvent } from "@formbricks/lib/posthogServer"; import { captureTelemetry } from "@formbricks/lib/telemetry"; -import { TPerson } from "@formbricks/types/v1/people"; -import { TResponse } from "@formbricks/types/v1/responses"; -import { TTag } from "@formbricks/types/v1/tags"; -import type { NextApiRequest, NextApiResponse } from "next"; +import { TResponse } from "@formbricks/types/responses"; +import { TTag } from "@formbricks/types/tags"; export default async function handle(req: NextApiRequest, res: NextApiResponse) { const environmentId = req.query.environmentId?.toString(); @@ -107,12 +108,15 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse) surveyId: true, finished: true, data: true, + ttc: true, meta: true, personAttributes: true, singleUseId: true, person: { select: { id: true, + userId: true, + environmentId: true, createdAt: true, updatedAt: true, attributes: { @@ -159,21 +163,6 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse) }, }); - const transformPrismaPerson = (person): TPerson => { - const attributes = person.attributes.reduce((acc, attr) => { - acc[attr.attributeClass.name] = attr.value; - return acc; - }, {} as Record); - - return { - id: person.id, - attributes: attributes, - createdAt: person.createdAt, - updatedAt: person.updatedAt, - environmentId: environmentId, - }; - }; - const responseData: TResponse = { ...responsePrisma, person: responsePrisma.person ? transformPrismaPerson(responsePrisma.person) : null, diff --git a/apps/web/pages/api/v1/client/environments/[environmentId]/sessions/index.ts b/apps/web/pages/api/v1/client/environments/[environmentId]/sessions/index.ts deleted file mode 100644 index e6e14053b5..0000000000 --- a/apps/web/pages/api/v1/client/environments/[environmentId]/sessions/index.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { createSession } from "@/app/lib/api/clientSession"; -import { getSettings } from "@/app/lib/api/clientSettings"; -import type { NextApiRequest, NextApiResponse } from "next"; - -export default async function handle(req: NextApiRequest, res: NextApiResponse) { - const environmentId = req.query.environmentId?.toString(); - - if (!environmentId) { - return res.status(400).json({ message: "Missing environmentId" }); - } - - // CORS - if (req.method === "OPTIONS") { - res.status(200).end(); - } - // GET - else if (req.method === "POST") { - const { personId } = req.body; - - if (!personId) { - return res.status(400).json({ message: "Missing personId" }); - } - - try { - const session = await createSession(personId); - const settings = await getSettings(environmentId, personId); - - return res.json({ session, settings }); - } catch (error) { - res.status(500).json({ message: error.message }); - } - } - - // Unknown HTTP Method - else { - throw new Error(`The HTTP ${req.method} method is not supported by this route.`); - } -} diff --git a/apps/web/pages/api/v1/client/surveys/[surveyId]/index.ts b/apps/web/pages/api/v1/client/surveys/[surveyId]/index.ts index 01269d2ac7..1ca0338100 100644 --- a/apps/web/pages/api/v1/client/surveys/[surveyId]/index.ts +++ b/apps/web/pages/api/v1/client/surveys/[surveyId]/index.ts @@ -1,6 +1,7 @@ -import { prisma } from "@formbricks/database"; import type { NextApiRequest, NextApiResponse } from "next"; +import { prisma } from "@formbricks/database"; + export default async function handle(req: NextApiRequest, res: NextApiResponse) { const surveyId = req.query.surveyId?.toString(); @@ -48,7 +49,7 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse) }, select: { brandColor: true, - formbricksSignature: true, + linkSurveyBranding: true, }, }); @@ -57,7 +58,7 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse) message: "Survey not running", reason: survey.status, brandColor: product?.brandColor, - formbricksSignature: product?.formbricksSignature, + formbricksSignature: product?.linkSurveyBranding, surveyClosedMessage: survey?.surveyClosedMessage, }); } @@ -66,7 +67,7 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse) return res.status(200).json({ ...survey, brandColor: product?.brandColor, - formbricksSignature: product?.formbricksSignature, + formbricksSignature: product?.linkSurveyBranding, }); } diff --git a/apps/web/pages/api/v1/environments/[environmentId]/api-keys/[apiKeyId]/index.ts b/apps/web/pages/api/v1/environments/[environmentId]/api-keys/[apiKeyId]/index.ts deleted file mode 100644 index 21f4844fb4..0000000000 --- a/apps/web/pages/api/v1/environments/[environmentId]/api-keys/[apiKeyId]/index.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { hasEnvironmentAccess } from "@/app/lib/api/apiHelper"; -import { prisma } from "@formbricks/database"; -import type { NextApiRequest, NextApiResponse } from "next"; - -export default async function handle(req: NextApiRequest, res: NextApiResponse) { - const environmentId = req.query.environmentId?.toString(); - const apiKeyId = req.query.apiKeyId?.toString(); - - if (!apiKeyId || !environmentId) { - return res.status(400).json({ message: "Missing apiKeyId or environmentId" }); - } - - if (!(await hasEnvironmentAccess(req, res, environmentId))) { - return res.status(401).json({ message: "Not authenticated" }); - } - - // DELETE /api/environments/:environmentId/api-keys/:apiKeyId - // Deletes an existing API Key - // Required fields in body: environmentId, apiKeyId - // Optional fields in body: - - if (req.method === "DELETE") { - const prismaRes = await prisma.apiKey.delete({ - where: { id: apiKeyId }, - }); - return res.json(prismaRes); - } - - // Unknown HTTP Method - else { - throw new Error(`The HTTP ${req.method} method is not supported by this route.`); - } -} diff --git a/apps/web/pages/api/v1/environments/[environmentId]/api-keys/index.ts b/apps/web/pages/api/v1/environments/[environmentId]/api-keys/index.ts deleted file mode 100644 index 28423f257a..0000000000 --- a/apps/web/pages/api/v1/environments/[environmentId]/api-keys/index.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { hasEnvironmentAccess, hashApiKey } from "@/app/lib/api/apiHelper"; -import { prisma } from "@formbricks/database"; -import { randomBytes } from "crypto"; -import type { NextApiRequest, NextApiResponse } from "next"; - -export default async function handle(req: NextApiRequest, res: NextApiResponse) { - const environmentId = req.query.environmentId?.toString(); - - if (!environmentId) { - return res.status(400).json({ message: "Missing environmentId" }); - } - - if (!(await hasEnvironmentAccess(req, res, environmentId))) { - return res.status(401).json({ message: "Not authenticated" }); - } - - // GET /api/environments/[environmentId]/api-keys/ - // Gets all ApiKeys of an environment - if (req.method === "GET") { - const apiKeys = await prisma.apiKey.findMany({ - where: { - environmentId, - }, - }); - return res.json(apiKeys); - } - - // POST /api/environments/:environmentId/api-keys - // Creates an API Key - // Optional fields in body: label - if (req.method === "POST") { - const apiKey = req.body; - const key = randomBytes(16).toString("hex"); - // Create API Key in the database - const result = await prisma.apiKey.create({ - data: { - ...apiKey, - hashedKey: hashApiKey(key), - environment: { connect: { id: environmentId } }, - }, - }); - res.json({ ...result, apiKey: key }); - } - - // Unknown HTTP Method - else { - throw new Error(`The HTTP ${req.method} method is not supported by this route.`); - } -} diff --git a/apps/web/pages/api/v1/environments/[environmentId]/attribute-classes/[attributeClassId]/index.ts b/apps/web/pages/api/v1/environments/[environmentId]/attribute-classes/[attributeClassId]/index.ts deleted file mode 100644 index 1afa9a70c1..0000000000 --- a/apps/web/pages/api/v1/environments/[environmentId]/attribute-classes/[attributeClassId]/index.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { hasEnvironmentAccess } from "@/app/lib/api/apiHelper"; -import { prisma } from "@formbricks/database"; -import type { NextApiRequest, NextApiResponse } from "next"; - -export default async function handle(req: NextApiRequest, res: NextApiResponse) { - const environmentId = req.query.environmentId?.toString(); - if (environmentId === undefined) { - return res.status(400).json({ message: "Missing environmentId" }); - } - - const attributeClassId = req.query.attributeClassId?.toString(); - if (attributeClassId === undefined) { - return res.status(400).json({ message: "Missing attributeClassId" }); - } - - const hasAccess = await hasEnvironmentAccess(req, res, environmentId); - if (!hasAccess) { - return res.status(403).json({ message: "Not authorized" }); - } - - // GET - if (req.method === "GET") { - const attributeClass = await prisma.attributeClass.findFirst({ - where: { - id: attributeClassId, - environmentId, - }, - }); - - const activeSurveysData = await prisma.surveyAttributeFilter.findMany({ - where: { - attributeClassId, - survey: { - status: "inProgress", - }, - }, - select: { - survey: { - select: { - name: true, - }, - }, - }, - }); - - const activeSurveys = activeSurveysData.map((t) => t.survey.name); - - const inactiveSurveysData = await prisma.surveyAttributeFilter.findMany({ - where: { - attributeClassId, - survey: { - status: { - in: ["paused", "completed"], - }, - }, - }, - select: { - survey: { - select: { - name: true, - }, - }, - }, - }); - const inactiveSurveys = inactiveSurveysData.map((t) => t.survey.name); - - return res.json({ - ...attributeClass, - activeSurveys, - inactiveSurveys, - }); - } - - // PUT - else if (req.method === "PUT") { - const currentAttributeClass = await prisma.attributeClass.findUnique({ - where: { - id: attributeClassId, - }, - }); - if (currentAttributeClass === null) { - return res.status(404).json({ message: "Attribute class not found" }); - } - if (currentAttributeClass.type === "automatic") { - return res.status(403).json({ message: "Automatic attribute classes cannot be updated" }); - } - - const attributeClass = await prisma.attributeClass.update({ - where: { - id: attributeClassId, - }, - data: { - ...req.body, - }, - }); - - return res.json(attributeClass); - } - - // Delete - else if (req.method === "DELETE") { - const currentAttributeClass = await prisma.attributeClass.findFirst({ - where: { - id: attributeClassId, - environmentId, - }, - }); - if (currentAttributeClass === null) { - return res.status(404).json({ message: "Attribute class not found" }); - } - if (currentAttributeClass.type === "automatic") { - return res.status(403).json({ message: "Automatic attribute classes cannot be deleted" }); - } - - const prismaRes = await prisma.survey.delete({ - where: { id: attributeClassId }, - }); - return res.json(prismaRes); - } - - // Unknown HTTP Method - else { - throw new Error(`The HTTP ${req.method} method is not supported by this route.`); - } -} diff --git a/apps/web/pages/api/v1/environments/[environmentId]/attribute-classes/index.ts b/apps/web/pages/api/v1/environments/[environmentId]/attribute-classes/index.ts deleted file mode 100644 index b6d738c394..0000000000 --- a/apps/web/pages/api/v1/environments/[environmentId]/attribute-classes/index.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { hasEnvironmentAccess } from "@/app/lib/api/apiHelper"; -import { prisma } from "@formbricks/database"; -import type { NextApiRequest, NextApiResponse } from "next"; - -export default async function handle(req: NextApiRequest, res: NextApiResponse) { - const environmentId = req.query.environmentId?.toString(); - - if (!environmentId) { - return res.status(400).json({ message: "Missing environmentId" }); - } - - if (!(await hasEnvironmentAccess(req, res, environmentId))) { - return res.status(401).json({ message: "Not authenticated" }); - } - - // GET - if (req.method === "GET") { - const attributeClasses = await prisma.attributeClass.findMany({ - where: { - environment: { - id: environmentId, - }, - }, - }); - - return res.json(attributeClasses); - } - - // Unknown HTTP Method - else { - throw new Error(`The HTTP ${req.method} method is not supported by this route.`); - } -} diff --git a/apps/web/pages/api/v1/environments/[environmentId]/event-classes/[eventClassId]/index.ts b/apps/web/pages/api/v1/environments/[environmentId]/event-classes/[eventClassId]/index.ts deleted file mode 100644 index 8c85afe8ef..0000000000 --- a/apps/web/pages/api/v1/environments/[environmentId]/event-classes/[eventClassId]/index.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { hasEnvironmentAccess } from "@/app/lib/api/apiHelper"; -import { prisma } from "@formbricks/database"; -import type { NextApiRequest, NextApiResponse } from "next"; - -export default async function handle(req: NextApiRequest, res: NextApiResponse) { - const environmentId = req.query.environmentId?.toString(); - - const eventClassId = req.query.eventClassId?.toString(); - - if (environmentId === undefined) { - return res.status(400).json({ message: "Missing environmentId" }); - } - if (eventClassId === undefined) { - return res.status(400).json({ message: "Missing eventClassId" }); - } - - const hasAccess = await hasEnvironmentAccess(req, res, environmentId); - if (!hasAccess) { - return res.status(403).json({ message: "Not authorized" }); - } - - // GET - if (req.method === "GET") { - const eventClass = await prisma.eventClass.findFirst({ - where: { - id: eventClassId, - environmentId, - }, - }); - - const numEventsLastHour = await prisma.event.count({ - where: { - eventClassId, - createdAt: { - gte: new Date(Date.now() - 60 * 60 * 1000), - }, - }, - }); - const numEventsLast24Hours = await prisma.event.count({ - where: { - eventClassId, - createdAt: { - gte: new Date(Date.now() - 24 * 60 * 60 * 1000), - }, - }, - }); - const numEventsLast7Days = await prisma.event.count({ - where: { - eventClassId, - createdAt: { - gte: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), - }, - }, - }); - const activeSurveysData = await prisma.surveyTrigger.findMany({ - where: { - eventClassId, - survey: { - status: "inProgress", - }, - }, - select: { - survey: { - select: { - name: true, - }, - }, - }, - }); - const activeSurveys = activeSurveysData.map((t) => t.survey.name); - - const inactiveSurveysData = await prisma.surveyTrigger.findMany({ - where: { - eventClassId, - survey: { - status: { - in: ["paused", "completed"], - }, - }, - }, - select: { - survey: { - select: { - name: true, - }, - }, - }, - }); - const inactiveSurveys = inactiveSurveysData.map((t) => t.survey.name); - - return res.json({ - ...eventClass, - numEventsLastHour, - numEventsLast24Hours, - numEventsLast7Days, - activeSurveys, - inactiveSurveys, - }); - } - - // PUT - else if (req.method === "PUT") { - const currentEventClass = await prisma.eventClass.findUnique({ - where: { - id: eventClassId, - }, - }); - if (currentEventClass === null) { - return res.status(404).json({ message: "Event class not found" }); - } - if (currentEventClass.type === "automatic") { - return res.status(403).json({ message: "Automatic event classes cannot be updated" }); - } - - const eventClass = await prisma.eventClass.update({ - where: { - id: eventClassId, - }, - data: { - ...req.body, - }, - }); - - return res.json(eventClass); - } - - // Delete - else if (req.method === "DELETE") { - const currentEventClass = await prisma.eventClass.findFirst({ - where: { - id: eventClassId, - environmentId, - }, - }); - if (currentEventClass === null) { - return res.status(404).json({ message: "Event class not found" }); - } - if (currentEventClass.type === "automatic") { - return res.status(403).json({ message: "Automatic event classes cannot be deleted" }); - } - - const prismaRes = await prisma.eventClass.delete({ - where: { id: eventClassId }, - }); - return res.json(prismaRes); - } - - // Unknown HTTP Method - else { - throw new Error(`The HTTP ${req.method} method is not supported by this route.`); - } -} diff --git a/apps/web/pages/api/v1/environments/[environmentId]/event-classes/index.ts b/apps/web/pages/api/v1/environments/[environmentId]/event-classes/index.ts deleted file mode 100644 index a84f7870e6..0000000000 --- a/apps/web/pages/api/v1/environments/[environmentId]/event-classes/index.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { hasEnvironmentAccess } from "@/app/lib/api/apiHelper"; -import { prisma } from "@formbricks/database"; -import type { NextApiRequest, NextApiResponse } from "next"; - -export default async function handle(req: NextApiRequest, res: NextApiResponse) { - const environmentId = req.query.environmentId?.toString(); - - if (!environmentId) { - return res.status(400).json({ message: "Missing environmentId" }); - } - - if (!(await hasEnvironmentAccess(req, res, environmentId))) { - return res.status(401).json({ message: "Not authenticated" }); - } - - // GET - if (req.method === "GET") { - const eventClasses = await prisma.eventClass.findMany({ - where: { - environment: { - id: environmentId, - }, - }, - include: { - _count: { - select: { - events: true, - }, - }, - }, - }); - - return res.json(eventClasses); - } - - // POST - else if (req.method === "POST") { - const eventClass = req.body; - - if (eventClass.type === "automatic") { - res.status(400).json({ message: "You are not allowed to create new automatic events" }); - } - - // check if eventClass already exists - const existingEventClass = await prisma.eventClass.findFirst({ - where: { - name: eventClass.name, - environment: { - id: environmentId, - }, - }, - }); - - if (existingEventClass) { - return res.status(409).json({ message: "EventClass already exists" }); - } - - // create eventClass in db - const result = await prisma.eventClass.create({ - data: { - ...eventClass, - environment: { connect: { id: environmentId } }, - }, - }); - res.json(result); - } - - // Unknown HTTP Method - else { - throw new Error(`The HTTP ${req.method} method is not supported by this route.`); - } -} diff --git a/apps/web/pages/api/v1/environments/[environmentId]/events/index.ts b/apps/web/pages/api/v1/environments/[environmentId]/events/index.ts deleted file mode 100644 index df3b7ce836..0000000000 --- a/apps/web/pages/api/v1/environments/[environmentId]/events/index.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { hasEnvironmentAccess } from "@/app/lib/api/apiHelper"; -import { prisma } from "@formbricks/database"; -import type { NextApiRequest, NextApiResponse } from "next"; - -export default async function handle(req: NextApiRequest, res: NextApiResponse) { - const environmentId = req.query.environmentId?.toString(); - - if (!environmentId) { - return res.status(400).json({ message: "Missing environmentId" }); - } - - const hasAccess = await hasEnvironmentAccess(req, res, environmentId); - if (!hasAccess) { - return res.status(403).json({ message: "Not authorized" }); - } - - // GET - if (req.method === "GET") { - const events = await prisma.event.findMany({ - where: { - eventClass: { - environmentId: environmentId, - }, - }, - orderBy: { - createdAt: "desc", - }, - take: 20, - include: { - eventClass: true, - }, - }); - - return res.json(events); - } - - /* // POST - else if (req.method === "POST") { - const eventClass = req.body; - - if (eventClass.type === "automatic") { - res.status(400).json({ message: "You are not allowed to create new automatic events" }); - } - - // create eventClass in db - const result = await prisma.eventClass.create({ - data: { - ...eventClass, - environment: { connect: { id: environmentId } }, - }, - }); - res.json(result); - } */ - - // Unknown HTTP Method - else { - throw new Error(`The HTTP ${req.method} method is not supported by this route.`); - } -} diff --git a/apps/web/pages/api/v1/environments/[environmentId]/index.ts b/apps/web/pages/api/v1/environments/[environmentId]/index.ts deleted file mode 100644 index 08f61c4907..0000000000 --- a/apps/web/pages/api/v1/environments/[environmentId]/index.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { hasEnvironmentAccess } from "@/app/lib/api/apiHelper"; -import { prisma } from "@formbricks/database"; -import type { NextApiRequest, NextApiResponse } from "next"; - -export default async function handle(req: NextApiRequest, res: NextApiResponse) { - const environmentId = req.query?.environmentId?.toString(); - - if (!environmentId) { - return res.status(400).json({ message: "Missing environmentId" }); - } - - const hasAccess = await hasEnvironmentAccess(req, res, environmentId); - if (!hasAccess) { - return res.status(403).json({ message: "Not authorized" }); - } - - // GET - if (req.method === "GET") { - const environment = await prisma.environment.findUnique({ - where: { - id: environmentId, - }, - include: { - product: { - select: { - id: true, - name: true, - teamId: true, - brandColor: true, - environments: true, - }, - }, - }, - }); - - if (environment === null) { - return res.status(404).json({ message: "This environment doesn't exist" }); - } - - const products = await prisma.product.findMany({ - where: { - teamId: environment.product.teamId, - }, - select: { - id: true, - name: true, - brandColor: true, - environments: { - where: { - type: "production", - }, - select: { - id: true, - }, - }, - }, - }); - - return res.json({ ...environment, availableProducts: products }); - } - - if (req.method === "PUT") { - const data = { ...req.body, updatedAt: new Date() }; - const prismaRes = await prisma.environment.update({ - where: { id: environmentId }, - data, - }); - return res.json(prismaRes); - } - - // Unknown HTTP Method - else { - throw new Error(`The HTTP ${req.method} method is not supported by this route.`); - } -} diff --git a/apps/web/pages/api/v1/environments/[environmentId]/members/index.ts b/apps/web/pages/api/v1/environments/[environmentId]/members/index.ts deleted file mode 100644 index 23c4143048..0000000000 --- a/apps/web/pages/api/v1/environments/[environmentId]/members/index.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { getSessionUser } from "@/app/lib/api/apiHelper"; -import { prisma } from "@formbricks/database"; -import type { NextApiRequest, NextApiResponse } from "next"; - -export default async function handle(req: NextApiRequest, res: NextApiResponse) { - // Check Authentication - const user: any = await getSessionUser(req, res); - if (!user) { - return res.status(401).json({ message: "Not authenticated" }); - } - - const environmentId = req.query.environmentId?.toString(); - if (environmentId === undefined) { - return res.status(400).json({ message: "Missing environmentId" }); - } - - if (req.method === "GET") { - const environment = await prisma.environment.findUnique({ - where: { id: environmentId }, - include: { - product: { - select: { - teamId: true, - team: { - select: { - memberships: { - where: { userId: user.id }, - }, - }, - }, - }, - }, - }, - }); - - if (!environment) { - return res.status(400).json({ message: "Invalid environment ID" }); - } - - const teamId = environment.product.teamId; - - if (!teamId || environment.product.team.memberships.length === 0) { - return res.status(403).json({ - message: "You don't have access to this organisation or this organisation doesn't exist", - }); - } - - const membersData = await prisma.membership.findMany({ - where: { teamId }, - select: { - user: { - select: { - name: true, - email: true, - }, - }, - userId: true, - accepted: true, - role: true, - }, - }); - const members = membersData.map((member) => { - return { - name: member.user?.name || "", - email: member.user?.email || "", - userId: member.userId, - accepted: member.accepted, - role: member.role, - }; - }); - - const inviteeData = await prisma.invite.findMany({ - where: { teamId, accepted: false }, - select: { - id: true, - name: true, - email: true, - acceptorId: true, - role: true, - accepted: true, - expiresAt: true, - }, - }); - const invitees = inviteeData.map((invite) => { - return { - name: invite.name, - email: invite.email, - inviteId: invite.id, - acceptorId: invite.acceptorId, - role: invite.role, - accepted: invite.accepted, - expiresAt: invite.expiresAt, - }; - }); - - return res.json({ members, invitees, teamId }); - } else { - throw new Error(`The HTTP ${req.method} method is not supported by this route.`); - } -} diff --git a/apps/web/pages/api/v1/environments/[environmentId]/people/[personId]/index.ts b/apps/web/pages/api/v1/environments/[environmentId]/people/[personId]/index.ts deleted file mode 100644 index e0434d9b38..0000000000 --- a/apps/web/pages/api/v1/environments/[environmentId]/people/[personId]/index.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { hasEnvironmentAccess } from "@/app/lib/api/apiHelper"; -import { prisma } from "@formbricks/database"; -import type { NextApiRequest, NextApiResponse } from "next"; - -export default async function handle(req: NextApiRequest, res: NextApiResponse) { - const environmentId = req.query.environmentId?.toString(); - - const personId = req.query.personId?.toString(); - - if (environmentId === undefined) { - return res.status(400).json({ message: "Missing environmentId" }); - } - if (personId === undefined) { - return res.status(400).json({ message: "Missing personId" }); - } - - const hasAccess = await hasEnvironmentAccess(req, res, environmentId); - if (!hasAccess) { - return res.status(403).json({ message: "Not authorized" }); - } - - // GET - if (req.method === "GET") { - const persons = await prisma.person.findFirst({ - where: { - id: personId, - environmentId, - }, - select: { - id: true, - createdAt: true, - updatedAt: true, - responses: { - select: { - id: true, - createdAt: true, - updatedAt: true, - data: true, - survey: { - select: { - id: true, - questions: true, - name: true, - status: true, - }, - }, - }, - }, - sessions: { - select: { - events: { - select: { - id: true, - createdAt: true, - eventClass: { - select: { - name: true, - description: true, - type: true, - }, - }, - }, - }, - }, - }, - attributes: { - select: { - id: true, - createdAt: true, - updatedAt: true, - value: true, - attributeClass: { - select: { - name: true, - description: true, - archived: true, - }, - }, - }, - }, - displays: { - select: { - id: true, - createdAt: true, - updatedAt: true, - surveyId: true, - }, - }, - }, - }); - - if (!persons) { - return res.status(404).json({ message: "Person not found" }); - } - - return res.json(persons); - } - - // POST - else if (req.method === "PUT") { - const data = { ...req.body, updatedAt: new Date() }; - const person = await prisma.person.update({ - where: { id: personId }, - data, - }); - return res.json(person); - } - - // Delete - else if (req.method === "DELETE") { - const person = await prisma.person.delete({ - where: { id: personId }, - }); - return res.json(person); - } - - // Unknown HTTP Method - else { - throw new Error(`The HTTP ${req.method} method is not supported by this route.`); - } -} diff --git a/apps/web/pages/api/v1/environments/[environmentId]/people/index.ts b/apps/web/pages/api/v1/environments/[environmentId]/people/index.ts deleted file mode 100644 index 86437c4cbd..0000000000 --- a/apps/web/pages/api/v1/environments/[environmentId]/people/index.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { hasEnvironmentAccess } from "@/app/lib/api/apiHelper"; -import { prisma } from "@formbricks/database"; -import type { NextApiRequest, NextApiResponse } from "next"; - -export default async function handle(req: NextApiRequest, res: NextApiResponse) { - const environmentId = req.query.environmentId?.toString(); - - if (!environmentId) { - return res.status(400).json({ message: "Missing environmentId" }); - } - - const hasAccess = await hasEnvironmentAccess(req, res, environmentId); - if (!hasAccess) { - return res.status(403).json({ message: "Not authorized" }); - } - - // GET - if (req.method === "GET") { - const people = await prisma.person.findMany({ - where: { - environment: { - id: environmentId, - }, - }, - include: { - attributes: { - select: { - id: true, - value: true, - attributeClass: { - select: { - id: true, - name: true, - }, - }, - }, - }, - _count: { - select: { sessions: true }, - }, - }, - }); - - return res.json(people); - } - - // Unknown HTTP Method - else { - throw new Error(`The HTTP ${req.method} method is not supported by this route.`); - } -} diff --git a/apps/web/pages/api/v1/environments/[environmentId]/product/index.ts b/apps/web/pages/api/v1/environments/[environmentId]/product/index.ts deleted file mode 100644 index 006c65cf5e..0000000000 --- a/apps/web/pages/api/v1/environments/[environmentId]/product/index.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { getSessionUser, hasEnvironmentAccess } from "@/app/lib/api/apiHelper"; -import { prisma } from "@formbricks/database"; -import { createProduct } from "@formbricks/lib/product/service"; -import type { NextApiRequest, NextApiResponse } from "next"; - -export default async function handle(req: NextApiRequest, res: NextApiResponse) { - const environmentId = req.query?.environmentId?.toString(); - - if (!environmentId) { - return res.status(400).json({ message: "Missing environmentId" }); - } - - const currentUser: any = await getSessionUser(req, res); - - const hasAccess = await hasEnvironmentAccess(req, res, environmentId); - if (!hasAccess) { - return res.status(403).json({ message: "Not authorized" }); - } - // GET - if (req.method === "GET") { - const environment = await prisma.environment.findUnique({ - where: { - id: environmentId, - }, - select: { - productId: true, - }, - }); - if (environment === null) { - return res.status(404).json({ message: "This environment doesn't exist" }); - } - const product = await prisma.product.findUnique({ - where: { - id: environment.productId, - }, - include: { - environments: { - select: { - id: true, - type: true, - }, - }, - }, - }); - - if (product === null) { - return res.status(404).json({ message: "This product doesn't exist" }); - } - return res.json(product); - } - - // PUT - else if (req.method === "PUT") { - const data = { ...req.body, updatedAt: new Date() }; - const environment = await prisma.environment.findUnique({ - where: { - id: environmentId, - }, - select: { - productId: true, - }, - }); - if (environment === null) { - return res.status(404).json({ message: "This environment doesn't exist" }); - } - const prismaRes = await prisma.product.update({ - where: { id: environment.productId }, - data, - }); - return res.json(prismaRes); - } - - // POST - else if (req.method === "POST") { - const { name } = req.body; - - // Get the teamId of the current environment - const environment = await prisma.environment.findUnique({ - where: { id: environmentId }, - select: { - product: { - select: { - teamId: true, - }, - }, - }, - }); - - if (!environment) { - res.status(404).json({ error: "Environment not found" }); - return; - } - - // Create a new product and associate it with the current team - const newProduct = await createProduct(environment.product.teamId, { name }); - - const firstEnvironment = newProduct.environments[0]; - res.json(firstEnvironment); - } - - // DELETE - else if (req.method === "DELETE") { - // get teamId from product - const environment = await prisma.environment.findUnique({ - where: { id: environmentId }, - select: { - product: { - select: { - id: true, - teamId: true, - }, - }, - }, - }); - if (!environment) { - res.status(404).json({ error: "Environment not found" }); - return; - } - const teamId = environment?.product.teamId; - - const membership = await prisma.membership.findUnique({ - where: { - userId_teamId: { - userId: currentUser.id, - teamId: teamId, - }, - }, - }); - if (membership?.role !== "admin" && membership?.role !== "owner") { - return res.status(403).json({ message: "You are not allowed to delete products." }); - } - const productId = environment?.product.id; - - if (environment === null) { - return res.status(404).json({ message: "This environment doesn't exist" }); - } - - // Delete the product with - const prismaRes = await prisma.product.delete({ - where: { id: productId }, - }); - - return res.json(prismaRes); - } - - // Unknown HTTP Method - else { - throw new Error(`The HTTP ${req.method} method is not supported by this route.`); - } -} diff --git a/apps/web/pages/api/v1/environments/[environmentId]/surveys/[surveyId]/duplicate/[targetEnvironmentId].ts b/apps/web/pages/api/v1/environments/[environmentId]/surveys/[surveyId]/duplicate/[targetEnvironmentId].ts deleted file mode 100644 index fd5911b9bc..0000000000 --- a/apps/web/pages/api/v1/environments/[environmentId]/surveys/[surveyId]/duplicate/[targetEnvironmentId].ts +++ /dev/null @@ -1,162 +0,0 @@ -import { hasEnvironmentAccess } from "@/app/lib/api/apiHelper"; -import { prisma } from "@formbricks/database"; -import { Prisma as prismaClient } from "@prisma/client/"; -import type { NextApiRequest, NextApiResponse } from "next"; - -export default async function handle(req: NextApiRequest, res: NextApiResponse) { - const environmentId = req.query.environmentId?.toString(); - const targetEnvironmentId = req.query.targetEnvironmentId?.toString(); - - const surveyId = req.query.surveyId?.toString(); - - if (environmentId === undefined) { - return res.status(400).json({ message: "Missing environmentId" }); - } - if (surveyId === undefined) { - return res.status(400).json({ message: "Missing surveyId" }); - } - if (targetEnvironmentId === undefined) { - return res.status(400).json({ message: "Missing targetEnvironmentId" }); - } - - const hasAccess = await hasEnvironmentAccess(req, res, environmentId); - const hasTargetEnvAccess = await hasEnvironmentAccess(req, res, targetEnvironmentId); - - if (!hasAccess || !hasTargetEnvAccess) { - return res.status(403).json({ message: "Not authorized" }); - } - - // POST - else if (req.method === "POST") { - // duplicate current survey including its triggers - const existingSurvey = await prisma.survey.findFirst({ - where: { - id: surveyId, - environmentId, - }, - include: { - triggers: { - include: { - eventClass: true, - }, - }, - attributeFilters: { - include: { - attributeClass: true, - }, - }, - }, - }); - - if (!existingSurvey) { - return res.status(404).json({ message: "Survey not found" }); - } - - let targetEnvironmentTriggers: string[] = []; - // map the local triggers to the target environment - for (const trigger of existingSurvey.triggers) { - const targetEnvironmentTrigger = await prisma.eventClass.findFirst({ - where: { - name: trigger.eventClass.name, - environment: { - id: targetEnvironmentId, - }, - }, - }); - if (!targetEnvironmentTrigger) { - // if the trigger does not exist in the target environment, create it - const newTrigger = await prisma.eventClass.create({ - data: { - name: trigger.eventClass.name, - environment: { - connect: { - id: targetEnvironmentId, - }, - }, - description: trigger.eventClass.description, - type: trigger.eventClass.type, - noCodeConfig: trigger.eventClass.noCodeConfig - ? JSON.parse(JSON.stringify(trigger.eventClass.noCodeConfig)) - : undefined, - }, - }); - targetEnvironmentTriggers.push(newTrigger.id); - } else { - targetEnvironmentTriggers.push(targetEnvironmentTrigger.id); - } - } - - let targetEnvironmentAttributeFilters: string[] = []; - // map the local attributeFilters to the target env - for (const attributeFilter of existingSurvey.attributeFilters) { - // check if attributeClass exists in target env. - // if not, create it - const targetEnvironmentAttributeClass = await prisma.attributeClass.findFirst({ - where: { - name: attributeFilter.attributeClass.name, - environment: { - id: targetEnvironmentId, - }, - }, - }); - if (!targetEnvironmentAttributeClass) { - const newAttributeClass = await prisma.attributeClass.create({ - data: { - name: attributeFilter.attributeClass.name, - description: attributeFilter.attributeClass.description, - type: attributeFilter.attributeClass.type, - environment: { - connect: { - id: targetEnvironmentId, - }, - }, - }, - }); - targetEnvironmentAttributeFilters.push(newAttributeClass.id); - } else { - targetEnvironmentAttributeFilters.push(targetEnvironmentAttributeClass.id); - } - } - - // create new survey with the data of the existing survey - const newSurvey = await prisma.survey.create({ - data: { - ...existingSurvey, - id: undefined, // id is auto-generated - environmentId: undefined, // environmentId is set below - name: `${existingSurvey.name} (copy)`, - status: "draft", - questions: JSON.parse(JSON.stringify(existingSurvey.questions)), - thankYouCard: JSON.parse(JSON.stringify(existingSurvey.thankYouCard)), - triggers: { - create: targetEnvironmentTriggers.map((eventClassId) => ({ - eventClassId: eventClassId, - })), - }, - attributeFilters: { - create: existingSurvey.attributeFilters.map((attributeFilter, idx) => ({ - attributeClassId: targetEnvironmentAttributeFilters[idx], - condition: attributeFilter.condition, - value: attributeFilter.value, - })), - }, - environment: { - connect: { - id: targetEnvironmentId, - }, - }, - surveyClosedMessage: existingSurvey.surveyClosedMessage ?? prismaClient.JsonNull, - singleUse: existingSurvey.singleUse ?? prismaClient.JsonNull, - productOverwrites: existingSurvey.productOverwrites ?? prismaClient.JsonNull, - verifyEmail: existingSurvey.verifyEmail ?? prismaClient.JsonNull, - }, - }); - - return res.json(newSurvey); - } - - // Unknown HTTP Method - else { - throw new Error(`The HTTP ${req.method} method is not supported by this route.`); - } -} diff --git a/apps/web/pages/api/v1/environments/[environmentId]/surveys/[surveyId]/duplicate/index.ts b/apps/web/pages/api/v1/environments/[environmentId]/surveys/[surveyId]/duplicate/index.ts deleted file mode 100644 index 93c53dc95f..0000000000 --- a/apps/web/pages/api/v1/environments/[environmentId]/surveys/[surveyId]/duplicate/index.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { hasEnvironmentAccess } from "@/app/lib/api/apiHelper"; -import { prisma } from "@formbricks/database"; -import { Prisma as prismaClient } from "@prisma/client/"; -import type { NextApiRequest, NextApiResponse } from "next"; - -export default async function handle(req: NextApiRequest, res: NextApiResponse) { - const environmentId = req.query.environmentId?.toString(); - - const surveyId = req.query.surveyId?.toString(); - - if (environmentId === undefined) { - return res.status(400).json({ message: "Missing environmentId" }); - } - if (surveyId === undefined) { - return res.status(400).json({ message: "Missing surveyId" }); - } - - const hasAccess = await hasEnvironmentAccess(req, res, environmentId); - if (!hasAccess) { - return res.status(403).json({ message: "Not authorized" }); - } - - // POST - else if (req.method === "POST") { - // duplicate current survey including its triggers - const existingSurvey = await prisma.survey.findFirst({ - where: { - id: surveyId, - environmentId, - }, - include: { - triggers: true, - attributeFilters: true, - }, - }); - - if (!existingSurvey) { - return res.status(404).json({ message: "Survey not found" }); - } - - // create new survey with the data of the existing survey - const newSurvey = await prisma.survey.create({ - data: { - ...existingSurvey, - id: undefined, // id is auto-generated - environmentId: undefined, // environmentId is set below - name: `${existingSurvey.name} (copy)`, - status: "draft", - questions: JSON.parse(JSON.stringify(existingSurvey.questions)), - thankYouCard: JSON.parse(JSON.stringify(existingSurvey.thankYouCard)), - triggers: { - create: existingSurvey.triggers.map((trigger) => ({ - eventClassId: trigger.eventClassId, - })), - }, - attributeFilters: { - create: existingSurvey.attributeFilters.map((attributeFilter) => ({ - attributeClassId: attributeFilter.attributeClassId, - condition: attributeFilter.condition, - value: attributeFilter.value, - })), - }, - environment: { - connect: { - id: environmentId, - }, - }, - surveyClosedMessage: existingSurvey.surveyClosedMessage ?? prismaClient.JsonNull, - singleUse: existingSurvey.singleUse ?? prismaClient.JsonNull, - productOverwrites: existingSurvey.productOverwrites ?? prismaClient.JsonNull, - verifyEmail: existingSurvey.verifyEmail ?? prismaClient.JsonNull, - }, - }); - - return res.json(newSurvey); - } - - // Unknown HTTP Method - else { - throw new Error(`The HTTP ${req.method} method is not supported by this route.`); - } -} diff --git a/apps/web/pages/api/v1/environments/[environmentId]/surveys/[surveyId]/index.ts b/apps/web/pages/api/v1/environments/[environmentId]/surveys/[surveyId]/index.ts deleted file mode 100644 index eb8cdf87a2..0000000000 --- a/apps/web/pages/api/v1/environments/[environmentId]/surveys/[surveyId]/index.ts +++ /dev/null @@ -1,264 +0,0 @@ -import type { AttributeFilter } from "@formbricks/types/surveys"; -import { hasEnvironmentAccess } from "@/app/lib/api/apiHelper"; -import { prisma } from "@formbricks/database"; -import { Prisma as prismaClient } from "@prisma/client/"; -import type { NextApiRequest, NextApiResponse } from "next"; - -export default async function handle(req: NextApiRequest, res: NextApiResponse) { - const environmentId = req.query.environmentId?.toString(); - - const surveyId = req.query.surveyId?.toString(); - - const analytics = req.query.analytics?.toString() === "true"; - - if (environmentId === undefined) { - return res.status(400).json({ message: "Missing environmentId" }); - } - if (surveyId === undefined) { - return res.status(400).json({ message: "Missing surveyId" }); - } - - const hasAccess = await hasEnvironmentAccess(req, res, environmentId); - if (!hasAccess) { - return res.status(403).json({ message: "Not authorized" }); - } - - // GET - if (req.method === "GET") { - const surveyData = await prisma.survey.findFirst({ - where: { - id: surveyId, - environmentId, - }, - include: { - triggers: true, - attributeFilters: true, - _count: analytics ? { select: { responses: { where: { finished: true } } } } : false, - }, - }); - - if (!surveyData) { - return res.status(404).json({ message: "Survey not found" }); - } - - const numDisplays = await prisma.display.count({ - where: { - surveyId, - }, - }); - - const numDisplaysResponded = await prisma.display.count({ - where: { - surveyId, - status: "responded", - }, - }); - - // responseRate, rounded to 2 decimal places - const responseRate = Math.round((numDisplaysResponded / numDisplays) * 100) / 100; - - return res.json({ - ...surveyData, - responseRate, - numDisplays, - triggers: surveyData.triggers.map((t) => t.eventClassId), - attributeFilters: surveyData.attributeFilters.map((f) => ({ - attributeClassId: f.attributeClassId, - condition: f.condition, - value: f.value, - })), - }); - } - - // PUT - else if (req.method === "PUT") { - const currentTriggers = await prisma.surveyTrigger.findMany({ - where: { - surveyId, - }, - }); - const currentAttributeFilters = await prisma.surveyAttributeFilter.findMany({ - where: { - surveyId, - }, - }); - let data: any = {}; - const body = { ...req.body }; - - delete body.updatedAt; - // preventing issue with unknowingly updating analytics - delete body._count; - - // delete unused fields for link surveys - if (body.type === "link") { - delete body.triggers; - delete body.recontactDays; - // converts JSON field with null value to JsonNull as JSON fields can't be set to null since prisma 3.0 - if (!body.surveyClosedMessage) { - body.surveyClosedMessage = prismaClient.JsonNull; - } - - if (!body.singleUse) { - body.singleUse = prismaClient.JsonNull; - } - - if (!body.verifyEmail) { - body.verifyEmail = prismaClient.JsonNull; - } - } - - if (body.triggers) { - const newTriggers: string[] = []; - const removedTriggers: string[] = []; - // find added triggers - for (const eventClassId of body.triggers) { - if (!eventClassId) { - continue; - } - if (currentTriggers.find((t) => t.eventClassId === eventClassId)) { - continue; - } else { - newTriggers.push(eventClassId); - } - } - // find removed triggers - for (const trigger of currentTriggers) { - if (body.triggers.find((t) => t === trigger.eventClassId)) { - continue; - } else { - removedTriggers.push(trigger.eventClassId); - } - } - // create new triggers - if (newTriggers.length > 0) { - data.triggers = { - ...(data.triggers || []), - create: newTriggers.map((eventClassId) => ({ - eventClassId, - })), - }; - } - // delete removed triggers - if (removedTriggers.length > 0) { - data.triggers = { - ...(data.triggers || []), - deleteMany: { - eventClassId: { - in: removedTriggers, - }, - }, - }; - } - delete body.triggers; - } - - const attributeFilters: AttributeFilter[] = body.attributeFilters; - - if (attributeFilters) { - const newFilters: AttributeFilter[] = []; - const removedFilterIds: string[] = []; - // find added attribute filters - for (const attributeFilter of attributeFilters) { - if (!attributeFilter.attributeClassId || !attributeFilter.condition || !attributeFilter.value) { - continue; - } - if ( - currentAttributeFilters.find( - (f) => - f.attributeClassId === attributeFilter.attributeClassId && - f.condition === attributeFilter.condition && - f.value === attributeFilter.value - ) - ) { - continue; - } else { - newFilters.push({ - attributeClassId: attributeFilter.attributeClassId, - condition: attributeFilter.condition, - value: attributeFilter.value, - }); - } - } - // find removed attribute filters - for (const attributeFilter of currentAttributeFilters) { - if ( - attributeFilters.find( - (f) => - f.attributeClassId === attributeFilter.attributeClassId && - f.condition === attributeFilter.condition && - f.value === attributeFilter.value - ) - ) { - continue; - } else { - removedFilterIds.push(attributeFilter.attributeClassId); - } - } - // create new attribute filters - if (newFilters.length > 0) { - data.attributeFilters = { - ...(data.attributeFilters || []), - create: newFilters.map((attributeFilter) => ({ - attributeClassId: attributeFilter.attributeClassId, - condition: attributeFilter.condition, - value: attributeFilter.value, - })), - }; - } - // delete removed triggers - if (removedFilterIds.length > 0) { - // delete all attribute filters that match the removed attribute classes - await Promise.all( - removedFilterIds.map(async (attributeClassId) => { - await prisma.surveyAttributeFilter.deleteMany({ - where: { - attributeClassId, - }, - }); - }) - ); - } - delete body.attributeFilters; - } - - data = { - ...data, - ...body, - }; - - // remove fields that are not in the survey model - delete data.responseRate; - delete data.numDisplays; - - if (data.surveyClosedMessage === null) { - data.surveyClosedMessage = prismaClient.JsonNull; - } - - if (data.singleUse === null) { - data.singleUse = prismaClient.JsonNull; - } - - if (data.verifyEmail === null) { - data.verifyEmail = prismaClient.JsonNull; - } - - const prismaRes = await prisma.survey.update({ - where: { id: surveyId }, - data, - }); - return res.json(prismaRes); - } - - // Delete - else if (req.method === "DELETE") { - const prismaRes = await prisma.survey.delete({ - where: { id: surveyId }, - }); - return res.json(prismaRes); - } - - // Unknown HTTP Method - else { - throw new Error(`The HTTP ${req.method} method is not supported by this route.`); - } -} diff --git a/apps/web/pages/api/v1/environments/[environmentId]/surveys/[surveyId]/responses/[submissionId]/index.ts b/apps/web/pages/api/v1/environments/[environmentId]/surveys/[surveyId]/responses/[submissionId]/index.ts deleted file mode 100644 index d48afe989e..0000000000 --- a/apps/web/pages/api/v1/environments/[environmentId]/surveys/[surveyId]/responses/[submissionId]/index.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { hasEnvironmentAccess } from "@/app/lib/api/apiHelper"; -import { prisma } from "@formbricks/database/src/client"; -import type { NextApiRequest, NextApiResponse } from "next"; - -export default async function handle(req: NextApiRequest, res: NextApiResponse) { - const environmentId = req.query.environmentId?.toString(); - const surveyId = req.query.surveyId?.toString(); - const responseId = req.query.submissionId?.toString(); - - if (!environmentId) { - return res.status(400).json({ message: "Missing environmentId" }); - } - if (!surveyId) { - return res.status(400).json({ message: "Missing surveyId" }); - } - if (!responseId) { - return res.status(400).json({ message: "Missing responseId" }); - } - - const hasAccess = await hasEnvironmentAccess(req, res, environmentId); - if (!hasAccess) { - return res.status(403).json({ message: "Not authorized" }); - } - - // GET /api/environments[environmentId]/surveys/[surveyId]/responses/[responseId] - // Get a specific response - if (req.method === "GET") { - const response = await prisma.response.findFirst({ - where: { - id: responseId, - surveyId: surveyId, - }, - }); - - return res.json(response); - } - - // POST /api/environments[environmentId]/surveys/[surveyId]/responses/[responseId] - // Replace a specific response - else if (req.method === "POST") { - const data = { ...req.body, updatedAt: new Date() }; - const prismaRes = await prisma.response.update({ - where: { id: responseId }, - data, - }); - return res.json(prismaRes); - } - - // Delete /api/environments[environmentId]/surveys/[surveyId]/responses/[responseId] - // Deletes a single survey - else if (req.method === "DELETE") { - const submissionId = req.query.submissionId?.toString(); - - try { - await prisma.display.delete({ - where: { - responseId: submissionId, - }, - }); - } catch (error) { - console.error(`No display found for submissionId: ${submissionId}`); - } - const prismaRes = await prisma.response.delete({ - where: { id: submissionId }, - }); - return res.json(prismaRes); - } - - // Unknown HTTP Method - else { - throw new Error(`The HTTP ${req.method} method is not supported by this route.`); - } -} diff --git a/apps/web/pages/api/v1/environments/[environmentId]/surveys/[surveyId]/responses/[submissionId]/responsesNotes/index.ts b/apps/web/pages/api/v1/environments/[environmentId]/surveys/[surveyId]/responses/[submissionId]/responsesNotes/index.ts deleted file mode 100644 index b8c473a09c..0000000000 --- a/apps/web/pages/api/v1/environments/[environmentId]/surveys/[surveyId]/responses/[submissionId]/responsesNotes/index.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { getSessionUser, hasEnvironmentAccess } from "@/app/lib/api/apiHelper"; -import { responses } from "@/app/lib/api/response"; -import { prisma } from "@formbricks/database"; -import type { NextApiRequest, NextApiResponse } from "next"; - -export default async function handle(req: NextApiRequest, res: NextApiResponse) { - const environmentId = req.query.environmentId?.toString(); - const responseId = req.query.submissionId?.toString(); - const surveyId = req.query.surveyId?.toString(); - - // Check Authentication - const currentUser: any = await getSessionUser(req, res); - if (!currentUser) { - return res.status(401).json({ message: "Not authenticated" }); - } - - // Check environmentId - if (environmentId === undefined) { - return res.status(400).json({ message: "Missing environmentId" }); - } - - // Check responseId - if (responseId === undefined) { - return res.status(400).json({ message: "Missing responseId" }); - } - - // Check surveyId - if (surveyId === undefined) { - return res.status(400).json({ message: "Missing surveyId" }); - } - - // Check whether user has access to the environment - const hasAccess = await hasEnvironmentAccess(req, res, environmentId); - if (!hasAccess) { - return res.status(403).json({ message: "Not authorized" }); - } - - // GET /api/environments[environmentId]/survey[surveyId]/responses/[responseId]/responsesNote - // Create a note to a response - if (req.method === "POST") { - const currentResponse = await prisma.response.findUnique({ - where: { - id: responseId, - }, - select: { - data: true, - survey: { - select: { - environmentId: true, - }, - }, - }, - }); - - if (!currentResponse) { - return responses.notFoundResponse("Response", responseId, true); - } - const responseNote = { - data: { - createdAt: new Date(), - updatedAt: new Date(), - response: { - connect: { - id: responseId, - }, - }, - user: { - connect: { - id: currentUser.id, - }, - }, - text: req.body, - }, - }; - - const newResponseNote = await prisma.responseNote.create(responseNote); - return res.json(newResponseNote); - } - - // Unknown HTTP Method - else { - throw new Error(`The HTTP ${req.method} method is not supported by this route.`); - } -} diff --git a/apps/web/pages/api/v1/environments/[environmentId]/surveys/[surveyId]/responses/[submissionId]/tags/[tagId]/index.ts b/apps/web/pages/api/v1/environments/[environmentId]/surveys/[surveyId]/responses/[submissionId]/tags/[tagId]/index.ts deleted file mode 100644 index 6187475720..0000000000 --- a/apps/web/pages/api/v1/environments/[environmentId]/surveys/[surveyId]/responses/[submissionId]/tags/[tagId]/index.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { hasEnvironmentAccess, getSessionUser } from "@/app/lib/api/apiHelper"; -import { prisma } from "@formbricks/database/src/client"; -import type { NextApiRequest, NextApiResponse } from "next"; - -export default async function handle(req: NextApiRequest, res: NextApiResponse) { - const environmentId = req.query.environmentId?.toString(); - const responseId = req.query.submissionId?.toString(); - const surveyId = req.query.surveyId?.toString(); - const tagId = req.query.tagId?.toString(); - - // Check Authentication - const currentUser = await getSessionUser(req, res); - if (!currentUser) { - return res.status(401).json({ message: "Not authenticated" }); - } - - // Check environmentId - if (!environmentId) { - return res.status(400).json({ message: "Invalid environmentId" }); - } - - // Check responseId - if (!responseId) { - return res.status(400).json({ message: "Invalid responseId" }); - } - - // Check surveyId - if (!surveyId) { - return res.status(400).json({ message: "Invalid surveyId" }); - } - - // Check tagId - if (!tagId) { - return res.status(400).json({ message: "Invalid tagId" }); - } - - // Check whether user has access to the environment - const hasAccess = await hasEnvironmentAccess(req, res, environmentId); - - if (!hasAccess) { - return res.status(403).json({ message: "You are not authorized to access this environment! " }); - } - - if (req.method === "DELETE") { - let deletedTag; - - try { - deletedTag = await prisma.tagsOnResponses.delete({ - where: { - responseId_tagId: { - responseId, - tagId, - }, - }, - }); - } catch (e) { - return res.status(500).json({ message: "Internal Server Error" }); - } - - return res.json(deletedTag); - } - - // Unknown HTTP Method - else { - throw new Error(`The HTTP ${req.method} method is not supported by this route.`); - } -} diff --git a/apps/web/pages/api/v1/environments/[environmentId]/surveys/[surveyId]/responses/[submissionId]/tags/index.ts b/apps/web/pages/api/v1/environments/[environmentId]/surveys/[surveyId]/responses/[submissionId]/tags/index.ts deleted file mode 100644 index 92f6412f93..0000000000 --- a/apps/web/pages/api/v1/environments/[environmentId]/surveys/[surveyId]/responses/[submissionId]/tags/index.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { getSessionUser, hasEnvironmentAccess } from "@/app/lib/api/apiHelper"; -import { responses } from "@/app/lib/api/response"; -import { prisma } from "@formbricks/database"; -import { Prisma } from "@prisma/client"; -import type { NextApiRequest, NextApiResponse } from "next"; - -export default async function handle(req: NextApiRequest, res: NextApiResponse) { - const environmentId = req.query.environmentId?.toString(); - const responseId = req.query.submissionId?.toString(); - const surveyId = req.query.surveyId?.toString(); - - // Check Authentication - const currentUser = await getSessionUser(req, res); - if (!currentUser) { - return res.status(401).json({ message: "Not authenticated" }); - } - - // Check environmentId - if (!environmentId) { - return res.status(400).json({ message: "Invalid environmentId" }); - } - - // Check responseId - if (!responseId) { - return res.status(400).json({ message: "Invalid responseId" }); - } - - // Check surveyId - if (!surveyId) { - return res.status(400).json({ message: "Invalid surveyId" }); - } - - // Check whether user has access to the environment - const hasAccess = await hasEnvironmentAccess(req, res, environmentId); - - if (!hasAccess) { - return res.status(403).json({ message: "You are not authorized to access this environment! " }); - } - - const currentResponse = await prisma.response.findUnique({ - where: { - id: responseId, - }, - select: { - data: true, - survey: { - select: { - environmentId: true, - }, - }, - }, - }); - - if (!currentResponse) { - return responses.notFoundResponse("Response", responseId, true); - } - - // GET /api/environments[environmentId]/survey[surveyId]/responses/[submissionId]/tags - - // Get all tags for a response - - if (req.method === "GET") { - let tags; - - try { - tags = await prisma.tagsOnResponses.findMany({ - where: { - responseId, - }, - include: { - response: true, - tag: true, - }, - }); - } catch (e) { - return res.status(500).json({ message: "Internal Server Error" }); - } - - return res.json(tags); - } - - // POST /api/environments[environmentId]/survey[surveyId]/responses/[submissionId]/tags - - // Create a tag for a response - - if (req.method === "POST") { - const tagId = req.body.tagId; - - if (!tagId) { - return res.status(400).json({ message: "Invalid tag Id" }); - } - - try { - await prisma.tagsOnResponses.create({ - data: { - responseId, - tagId, - }, - }); - } catch (e) { - if (e instanceof Prisma.PrismaClientKnownRequestError) { - if (e.code === "P2002") { - return res.status(400).json({ message: "Tag already exists" }); - } - } - - return res.status(500).json({ message: "Internal Server Error" }); - } - - return res.json({ - success: true, - message: `Tag ${tagId} created for response ${responseId}`, - }); - } - - // Unknown HTTP Method - else { - throw new Error(`The HTTP ${req.method} method is not supported by this route.`); - } -} diff --git a/apps/web/pages/api/v1/environments/[environmentId]/surveys/[surveyId]/responses/index.ts b/apps/web/pages/api/v1/environments/[environmentId]/surveys/[surveyId]/responses/index.ts deleted file mode 100644 index 9cfdcba232..0000000000 --- a/apps/web/pages/api/v1/environments/[environmentId]/surveys/[surveyId]/responses/index.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { hasEnvironmentAccess } from "@/app/lib/api/apiHelper"; -import { prisma } from "@formbricks/database"; - -import type { NextApiRequest, NextApiResponse } from "next"; - -export default async function handle(req: NextApiRequest, res: NextApiResponse) { - const environmentId = req.query.environmentId?.toString(); - const surveyId = req.query.surveyId?.toString(); - - if (environmentId === undefined) { - return res.status(400).json({ message: "Missing environmentId" }); - } - if (surveyId === undefined) { - return res.status(400).json({ message: "Missing surveyId" }); - } - - const hasAccess = await hasEnvironmentAccess(req, res, environmentId); - if (!hasAccess) { - return res.status(403).json({ message: "Not authorized" }); - } - - // GET - if (req.method === "GET") { - // get responses - const responses = await prisma.response.findMany({ - where: { - survey: { - id: surveyId, - }, - }, - orderBy: [ - { - createdAt: "desc", - }, - ], - include: { - person: { - include: { - attributes: { - select: { - attributeClass: { - select: { - id: true, - name: true, - }, - }, - value: true, - }, - }, - }, - }, - notes: { - include: { - response: true, - user: true, - }, - }, - tags: { - select: { - tag: { - select: { - name: true, - createdAt: true, - environmentId: true, - id: true, - updatedAt: true, - }, - }, - }, - }, - }, - }); - - return res.json({ count: responses.length, responses, reachedLimit: false }); - } - - // Unknown HTTP Method - else { - throw new Error(`The HTTP ${req.method} method is not supported by this route.`); - } -} diff --git a/apps/web/pages/api/v1/environments/[environmentId]/surveys/index.ts b/apps/web/pages/api/v1/environments/[environmentId]/surveys/index.ts deleted file mode 100644 index 758cff70e1..0000000000 --- a/apps/web/pages/api/v1/environments/[environmentId]/surveys/index.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { hasEnvironmentAccess } from "@/app/lib/api/apiHelper"; -import { prisma } from "@formbricks/database"; -import { captureTelemetry } from "@formbricks/lib/telemetry"; -import type { NextApiRequest, NextApiResponse } from "next"; - -export default async function handle(req: NextApiRequest, res: NextApiResponse) { - const environmentId = req.query.environmentId?.toString(); - - if (!environmentId) { - return res.status(400).json({ message: "Missing environmentId" }); - } - - const hasAccess = await hasEnvironmentAccess(req, res, environmentId); - if (!hasAccess) { - return res.status(403).json({ message: "Not authorized" }); - } - - // GET /api/environments[environmentId]/surveys - // Get a specific environment - if (req.method === "GET") { - const surveys = await prisma.survey.findMany({ - where: { - environment: { - id: environmentId, - }, - }, - include: { - _count: { - select: { responses: true }, - }, - }, - }); - - return res.json(surveys); - } - - // POST /api/environments[environmentId]/surveys - // Create a new survey - // Required fields in body: - - // Optional fields in body: label, schema - else if (req.method === "POST") { - const survey = req.body; - - // create survey in db - const result = await prisma.survey.create({ - data: { - ...survey, - environment: { connect: { id: environmentId } }, - }, - }); - - captureTelemetry("survey created"); - - res.json(result); - } - - // Unknown HTTP Method - else { - throw new Error(`The HTTP ${req.method} method is not supported by this route.`); - } -} diff --git a/apps/web/pages/api/v1/environments/[environmentId]/tags/[tagId]/index.ts b/apps/web/pages/api/v1/environments/[environmentId]/tags/[tagId]/index.ts deleted file mode 100644 index 06b1baac2b..0000000000 --- a/apps/web/pages/api/v1/environments/[environmentId]/tags/[tagId]/index.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { hasEnvironmentAccess, getSessionUser } from "@/app/lib/api/apiHelper"; -import { prisma } from "@formbricks/database/src/client"; -import { DatabaseError } from "@formbricks/types/v1/errors"; -import { TTag } from "@formbricks/types/v1/tags"; -import { Prisma } from "@prisma/client"; -import type { NextApiRequest, NextApiResponse } from "next"; - -export default async function handle(req: NextApiRequest, res: NextApiResponse) { - const environmentId = req.query.environmentId?.toString(); - const tagId = req.query.tagId?.toString(); - - // Check Authentication - const currentUser = await getSessionUser(req, res); - if (!currentUser) { - return res.status(401).json({ message: "Not authenticated" }); - } - - // Check environmentId - if (!environmentId) { - return res.status(400).json({ message: "Invalid environmentId" }); - } - - // Check tagId - if (!tagId) { - return res.status(400).json({ message: "Invalid tagId" }); - } - - // Check whether user has access to the environment - const hasAccess = await hasEnvironmentAccess(req, res, environmentId); - - if (!hasAccess) { - return res.status(403).json({ message: "You are not authorized to access this environment! " }); - } - - // PATCH /api/environments/[environmentId]/product/[productId]/tags/[tagId] - // Update a tag for a product - - if (req.method === "PATCH") { - const { name } = req.body; - - if (!name) { - return res.status(400).json({ message: "Invalid name" }); - } - - let tag: TTag; - - try { - tag = await prisma.tag.update({ - where: { - id: tagId, - }, - data: { - name: name, - }, - }); - } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { - if (error.code === "P2002") { - res.status(400).send({ message: "Tag already exists" }); - } - - throw new DatabaseError(error.message); - } - - throw error; - } - - return res.json(tag); - } - - // DELETE /api/environments/[environmentId]/tags/[tagId] - // Delete a tag for a product - - if (req.method === "DELETE") { - let tag: TTag; - - try { - tag = await prisma.tag.delete({ - where: { - id: tagId, - }, - }); - } catch (e) { - return res.status(500).json({ message: "Internal Server Error" }); - } - - return res.json(tag); - } - - // Unknown HTTP Method - else { - throw new Error(`The HTTP ${req.method} method is not supported by this route.`); - } -} diff --git a/apps/web/pages/api/v1/environments/[environmentId]/tags/count.ts b/apps/web/pages/api/v1/environments/[environmentId]/tags/count.ts deleted file mode 100644 index 88014fa2b7..0000000000 --- a/apps/web/pages/api/v1/environments/[environmentId]/tags/count.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { hasEnvironmentAccess, getSessionUser } from "@/app/lib/api/apiHelper"; -import { prisma } from "@formbricks/database/src/client"; -import type { NextApiRequest, NextApiResponse } from "next"; - -export default async function handle(req: NextApiRequest, res: NextApiResponse) { - const environmentId = req.query.environmentId?.toString(); - - // Check Authentication - const currentUser = await getSessionUser(req, res); - if (!currentUser) { - return res.status(401).json({ message: "Not authenticated" }); - } - - // Check environmentId - if (!environmentId) { - return res.status(400).json({ message: "Invalid environmentId" }); - } - - // Check whether user has access to the environment - const hasAccess = await hasEnvironmentAccess(req, res, environmentId); - - if (!hasAccess) { - return res.status(403).json({ message: "You are not authorized to access this environment! " }); - } - - // GET /api/environments/[environmentId]/tags/count - - // Get the count of tags on responses - - if (req.method === "GET") { - let tagsCounts; - - try { - tagsCounts = await prisma.tagsOnResponses.groupBy({ - by: ["tagId"], - _count: { - _all: true, - }, - }); - } catch (e) { - return res.status(500).json({ message: "Internal Server Error" }); - } - - return res.json(tagsCounts); - } - - // Unknown HTTP Method - else { - throw new Error(`The HTTP ${req.method} method is not supported by this route.`); - } -} diff --git a/apps/web/pages/api/v1/environments/[environmentId]/tags/index.ts b/apps/web/pages/api/v1/environments/[environmentId]/tags/index.ts deleted file mode 100644 index 8b3c9e9a14..0000000000 --- a/apps/web/pages/api/v1/environments/[environmentId]/tags/index.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { getSessionUser, hasEnvironmentAccess } from "@/app/lib/api/apiHelper"; -import { prisma } from "@formbricks/database/src/client"; -import { TTag } from "@formbricks/types/v1/tags"; -import { Prisma } from "@prisma/client"; -import type { NextApiRequest, NextApiResponse } from "next"; - -export default async function handle(req: NextApiRequest, res: NextApiResponse) { - const environmentId = req.query.environmentId?.toString(); - - // Check Authentication - const currentUser = await getSessionUser(req, res); - if (!currentUser) { - return res.status(401).json({ message: "Not authenticated" }); - } - - // Check environmentId - if (!environmentId) { - return res.status(400).json({ message: "Invalid environmentId" }); - } - - // Check whether user has access to the environment - const hasAccess = await hasEnvironmentAccess(req, res, environmentId); - - if (!hasAccess) { - return res.status(403).json({ message: "You are not authorized to access this environment! " }); - } - - // GET /api/environments/[environmentId]/tags - - // Get all tags for an environment - - if (req.method === "GET") { - let tags; - - try { - tags = await prisma.tag.findMany({ - where: { - environmentId, - }, - }); - } catch (e) { - return res.status(500).json({ message: "Internal Server Error" }); - } - - return res.json(tags); - } - - // POST /api/environments/[environmentId]/product/[productId]/tags - - // Create a new tag for an environment - - if (req.method === "POST") { - const name = req.body.name; - - if (!name) { - return res.status(400).json({ message: "Invalid name" }); - } - - let tag: TTag; - - try { - tag = await prisma.tag.create({ - data: { - name, - environmentId, - }, - }); - } catch (e) { - if (e instanceof Prisma.PrismaClientKnownRequestError) { - if (e.code === "P2002") { - return res.status(400).json({ duplicateRecord: true }); - } - } - return res.status(500).json({ message: "Internal Server Error" }); - } - - return res.json(tag); - } - - // Unknown HTTP Method - else { - throw new Error(`The HTTP ${req.method} method is not supported by this route.`); - } -} diff --git a/apps/web/pages/api/v1/environments/[environmentId]/tags/merge.ts b/apps/web/pages/api/v1/environments/[environmentId]/tags/merge.ts deleted file mode 100644 index 1e392f43ee..0000000000 --- a/apps/web/pages/api/v1/environments/[environmentId]/tags/merge.ts +++ /dev/null @@ -1,174 +0,0 @@ -import { hasEnvironmentAccess, getSessionUser } from "@/app/lib/api/apiHelper"; -import { prisma } from "@formbricks/database/src/client"; -import { TTag } from "@formbricks/types/v1/tags"; -import type { NextApiRequest, NextApiResponse } from "next"; - -export default async function handle(req: NextApiRequest, res: NextApiResponse) { - const environmentId = req.query.environmentId?.toString(); - - // Check Authentication - const currentUser = await getSessionUser(req, res); - if (!currentUser) { - return res.status(401).json({ message: "Not authenticated" }); - } - - // Check environmentId - if (!environmentId) { - return res.status(400).json({ message: "Invalid environmentId" }); - } - - // Check whether user has access to the environment - const hasAccess = await hasEnvironmentAccess(req, res, environmentId); - - if (!hasAccess) { - return res.status(403).json({ message: "You are not authorized to access this environment! " }); - } - - // POST /api/environments/[environmentId]/tags/merge - - // Merge tags together - - if (req.method === "PATCH") { - const { originalTagId, newTagId } = req.body; - - if (!originalTagId) { - return res.status(400).json({ message: "Invalid Tag Id" }); - } - - if (!newTagId) { - return res.status(400).json({ message: "Invalid Tag Id" }); - } - - let originalTag: TTag | null; - - originalTag = await prisma.tag.findUnique({ - where: { - id: originalTagId, - }, - }); - - if (!originalTag) { - return res.status(404).json({ message: "Tag not found" }); - } - - let newTag: TTag | null; - - newTag = await prisma.tag.findUnique({ - where: { - id: newTagId, - }, - }); - - if (!newTag) { - return res.status(404).json({ message: "Tag not found" }); - } - - // finds all the responses that have both the tags - - let responsesWithBothTags = await prisma.response.findMany({ - where: { - AND: [ - { - tags: { - some: { - tagId: { - in: [originalTagId], - }, - }, - }, - }, - { - tags: { - some: { - tagId: { - in: [newTagId], - }, - }, - }, - }, - ], - }, - }); - - if (!!responsesWithBothTags?.length) { - try { - await Promise.all( - responsesWithBothTags.map(async (response) => { - await prisma.$transaction([ - prisma.tagsOnResponses.deleteMany({ - where: { - responseId: response.id, - tagId: { - in: [originalTagId, newTagId], - }, - }, - }), - - prisma.tagsOnResponses.create({ - data: { - responseId: response.id, - tagId: newTagId, - }, - }), - ]); - }) - ); - - await prisma.$transaction([ - prisma.tagsOnResponses.updateMany({ - where: { - tagId: originalTagId, - }, - data: { - tagId: newTagId, - }, - }), - - prisma.tag.delete({ - where: { - id: originalTagId, - }, - }), - ]); - - return res.json({ - success: true, - message: "Tag merged successfully", - }); - } catch (err) { - return res.status(500).json({ message: "Internal Server Error" }); - } - } - - try { - await prisma.$transaction([ - prisma.tagsOnResponses.updateMany({ - where: { - tagId: originalTagId, - }, - data: { - tagId: newTagId, - }, - }), - - prisma.tag.delete({ - where: { - id: originalTagId, - }, - }), - ]); - } catch (e) { - return res.status(500).json({ message: "Internal Server Error" }); - } - - return res.json({ - success: true, - message: "Tag merged successfully", - }); - } - - // Unknown HTTP Method - else { - throw new Error(`The HTTP ${req.method} method is not supported by this route.`); - } -} diff --git a/apps/web/pages/api/v1/environments/[environmentId]/team/index.ts b/apps/web/pages/api/v1/environments/[environmentId]/team/index.ts deleted file mode 100644 index f0daa226e1..0000000000 --- a/apps/web/pages/api/v1/environments/[environmentId]/team/index.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { getSessionUser, hasEnvironmentAccess, isOwner } from "@/app/lib/api/apiHelper"; -import { prisma } from "@formbricks/database"; -import type { NextApiRequest, NextApiResponse } from "next"; - -export default async function handle(req: NextApiRequest, res: NextApiResponse) { - const currentUser: any = await getSessionUser(req, res); - if (!currentUser) { - return res.status(401).json({ message: "Not authenticated" }); - } - const environmentId = req.query?.environmentId?.toString(); - - if (!environmentId) { - return res.status(400).json({ message: "Missing environmentId" }); - } - - const hasAccess = await hasEnvironmentAccess(req, res, environmentId); - if (!hasAccess) { - return res.status(403).json({ message: "Not authorized" }); - } - // GET - if (req.method === "GET") { - const environment = await prisma.environment.findUnique({ - where: { - id: environmentId, - }, - select: { - product: { - select: { - teamId: true, - }, - }, - }, - }); - if (environment === null) { - return res.status(404).json({ message: "This environment doesn't exist" }); - } - const team = await prisma.team.findUnique({ - where: { - id: environment.product.teamId, - }, - select: { - id: true, - name: true, - stripeCustomerId: true, - plan: true, - }, - }); - - if (team === null) { - return res.status(404).json({ message: "This team doesn't exist" }); - } - return res.json(team); - } - - // DELETE - else if (req.method === "DELETE") { - try { - const environment = await prisma.environment.findUnique({ - where: { - id: environmentId, - }, - select: { - product: { - select: { - teamId: true, - }, - }, - }, - }); - if (environment === null) { - return res.status(404).json({ message: "This environment doesn't exist" }); - } - - const team = await prisma.team.findUnique({ - where: { - id: environment.product.teamId, - }, - select: { - id: true, - name: true, - stripeCustomerId: true, - plan: true, - }, - }); - if (team === null) { - return res.status(404).json({ message: "This team doesn't exist" }); - } - - const hasOwnership = isOwner(currentUser, team.id); - if (!hasOwnership) { - return res.status(403).json({ message: "You are not allowed to delete this team" }); - } - - const prismaRes = await prisma.team.delete({ - where: { - id: team.id, - }, - }); - - return res.status(200).json({ deletedTeam: prismaRes }); - } catch (error) { - return res.status(500).json({ message: error.message }); - } - } - // Unknown HTTP Method - else { - throw new Error(`The HTTP ${req.method} method is not supported by this route.`); - } -} diff --git a/apps/web/pages/api/v1/teams/[teamId]/index.ts b/apps/web/pages/api/v1/teams/[teamId]/index.ts deleted file mode 100644 index e090b7f81a..0000000000 --- a/apps/web/pages/api/v1/teams/[teamId]/index.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { getSessionUser, hasTeamAccess } from "@/app/lib/api/apiHelper"; -import { prisma } from "@formbricks/database"; -import type { NextApiRequest, NextApiResponse } from "next"; - -export default async function handle(req: NextApiRequest, res: NextApiResponse) { - // Check Authentication - - const currentUser: any = await getSessionUser(req, res); - if (!currentUser) { - return res.status(401).json({ message: "Not authenticated" }); - } - - const teamId = req.query.teamId?.toString(); - if (teamId === undefined) { - return res.status(400).json({ message: "Missing teamId" }); - } - - const hasAccess = await hasTeamAccess(currentUser, teamId); - if (!hasAccess) { - return res.status(403).json({ message: "Not authorized" }); - } - - // PUT /api/v1/teams/[teamId] - // Update a team - if (req.method === "PUT") { - const { name } = req.body; - if (name === undefined) { - return res.status(400).json({ message: "Missing name" }); - } - - // check if currentUser is owner of the team - const membership = await prisma.membership.findUnique({ - where: { - userId_teamId: { - userId: currentUser.id, - teamId, - }, - }, - }); - if (membership?.role !== "owner" && membership?.role !== "admin") { - return res.status(403).json({ message: "You are not allowed to update this team" }); - } - - // update team - const team = await prisma.team.update({ - where: { - id: teamId, - }, - data: { - name, - }, - }); - return res.json(team); - } - - // Unknown HTTP Method - else { - throw new Error(`The HTTP ${req.method} method is not supported by this route.`); - } -} diff --git a/apps/web/pages/api/v1/teams/[teamId]/invite/[inviteId]/index.ts b/apps/web/pages/api/v1/teams/[teamId]/invite/[inviteId]/index.ts deleted file mode 100644 index 961f98e0f5..0000000000 --- a/apps/web/pages/api/v1/teams/[teamId]/invite/[inviteId]/index.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { getSessionUser, isAdminOrOwner } from "@/app/lib/api/apiHelper"; -import { sendInviteMemberEmail } from "@/app/lib/email"; -import { createInviteToken } from "@formbricks/lib/jwt"; -import { prisma } from "@formbricks/database"; -import type { NextApiRequest, NextApiResponse } from "next"; - -export default async function handle(req: NextApiRequest, res: NextApiResponse) { - // Check Authentication - const currentUser: any = await getSessionUser(req, res); - if (!currentUser) { - return res.status(401).json({ message: "Not authenticated" }); - } - - const teamId = req.query.teamId?.toString(); - if (teamId === undefined) { - return res.status(400).json({ message: "Missing teamId" }); - } - - const inviteId = req.query.inviteId?.toString(); - if (inviteId === undefined) { - return res.status(400).json({ message: "Missing inviteId" }); - } - - const hasOwnerOrAdminAccess = await isAdminOrOwner(currentUser, teamId); - if (!hasOwnerOrAdminAccess) { - return res.status(403).json({ message: "You are not allowed to create or modify invites in this team" }); - } - - // PATCH /api/v1/teams/[teamId]/invite/[inviteId] - // Update an invited member's role - if (req.method === "PATCH") { - const { role } = req.body; - // check if invite exists - const invite = await prisma.invite.findUnique({ - where: { - id: inviteId, - }, - select: { - creator: true, - email: true, - name: true, - }, - }); - - if (!invite) { - return res.status(403).json({ message: "You are not allowed to update this invite", invite }); - } - - // update invite with new role - const updatedInvite = await prisma.invite.update({ - where: { - id: inviteId, - }, - data: { - role, - }, - }); - return res.status(200).json(updatedInvite); - } - - // DELETE /api/v1/teams/[teamId]/invite/[inviteId] - // Remove a member from a team - if (req.method === "DELETE") { - // check if currentUser is owner of the team - const membership = await prisma.membership.findUnique({ - where: { - userId_teamId: { - userId: currentUser.id, - teamId, - }, - }, - }); - if (membership?.role !== "owner" && membership?.role !== "admin") { - return res.status(403).json({ message: "You are not allowed to delete members from this team" }); - } - - //delete invite - const inviteToDelete = await prisma.invite.delete({ - where: { - id: inviteId, - }, - }); - return res.json(inviteToDelete); - } - // PUT /api/v1/teams/[teamId]/invite/[inviteId] - // Renew an invite - else if (req.method === "PUT") { - // resend invite mail to user and update invite expiration date - const invite = await prisma.invite.findUnique({ - where: { - id: inviteId, - }, - select: { - creator: true, - email: true, - name: true, - }, - }); - - if (!invite) { - return res.status(403).json({ message: "You are not allowed to resend this invite" }); - } - await sendInviteMemberEmail(inviteId, invite?.creator.name, invite?.name, invite?.email); - - const updatedInvite = await prisma.invite.update({ - where: { - id: inviteId, - }, - data: { - expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7), - }, - }); - - return res.status(200).json(updatedInvite); - } - // GET /api/v1/teams/[teamId]/invite/[inviteId] - // Retrieve an invite token - else if (req.method === "GET") { - const invite = await prisma.invite.findUnique({ - where: { - id: inviteId, - }, - select: { - email: true, - }, - }); - - if (!invite) { - return res.status(403).json({ message: "You are not allowed to share this invite link" }); - } - - const inviteToken = createInviteToken(inviteId, invite?.email, { - expiresIn: "7d", - }); - - return res.status(200).json({ inviteToken: encodeURIComponent(inviteToken) }); - } - - // Unknown HTTP Method - else { - throw new Error(`The HTTP ${req.method} method is not supported by this route.`); - } -} diff --git a/apps/web/pages/api/v1/teams/[teamId]/invite/index.ts b/apps/web/pages/api/v1/teams/[teamId]/invite/index.ts deleted file mode 100644 index 93ad3ef23e..0000000000 --- a/apps/web/pages/api/v1/teams/[teamId]/invite/index.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { getSessionUser, hasTeamAccess, isAdminOrOwner } from "@/app/lib/api/apiHelper"; -import { sendInviteMemberEmail } from "@/app/lib/email"; -import { prisma } from "@formbricks/database"; -import { INVITE_DISABLED } from "@formbricks/lib/constants"; -import type { NextApiRequest, NextApiResponse } from "next"; - -export default async function handle(req: NextApiRequest, res: NextApiResponse) { - // Check Authentication - const currentUser: any = await getSessionUser(req, res); - if (!currentUser) { - return res.status(401).json({ message: "Not authenticated" }); - } - - const teamId = req.query.teamId?.toString(); - if (teamId === undefined) { - return res.status(400).json({ message: "Missing teamId" }); - } - - const hasAccess = await hasTeamAccess(currentUser, teamId); - if (!hasAccess) { - return res.status(403).json({ message: "Not authorized" }); - } - - if (INVITE_DISABLED) { - return res.status(403).json({ message: "Invite Disabled" }); - } - - const hasOwnerOrAdminAccess = await isAdminOrOwner(currentUser, teamId); - if (!hasOwnerOrAdminAccess) { - return res.status(403).json({ message: "Not authorized" }); - } - - // POST /api/v1/teams/[teamId]/invite - if (req.method === "POST") { - let { email, name, role } = req.body; - email = email.toLowerCase(); - - const existingInvite = await prisma.invite.findFirst({ where: { email, teamId } }); - if (existingInvite) { - return res.status(409).json({ message: "Invite already exists" }); - } - - const user = await prisma.user.findUnique({ where: { email } }); - - if (user) { - const member = await prisma.membership.findUnique({ - where: { - userId_teamId: { teamId, userId: user.id }, - }, - }); - if (member) { - return res.status(409).json({ message: "User is already a member of this team" }); - } - } - - const expiresIn = 7 * 24 * 60 * 60 * 1000; // 7 days - const expiresAt = new Date(Date.now() + expiresIn); - - const invite = await prisma.invite.create({ - data: { - email, - name, - team: { connect: { id: teamId } }, - creator: { connect: { id: currentUser.id } }, - acceptor: user ? { connect: { id: user.id } } : undefined, - role, - expiresAt, - }, - }); - - await sendInviteMemberEmail(invite.id, currentUser.name, name, email); - - return res.status(201).json(invite); - } - - // Unknown HTTP Method - else { - throw new Error(`The HTTP ${req.method} method is not supported by this route.`); - } -} diff --git a/apps/web/pages/api/v1/teams/[teamId]/members/[userId]/index.ts b/apps/web/pages/api/v1/teams/[teamId]/members/[userId]/index.ts deleted file mode 100644 index 2b9a4c1ffd..0000000000 --- a/apps/web/pages/api/v1/teams/[teamId]/members/[userId]/index.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { getSessionUser, hasTeamAccess, isAdminOrOwner } from "@/app/lib/api/apiHelper"; -import { prisma } from "@formbricks/database"; -import type { NextApiRequest, NextApiResponse } from "next"; - -export default async function handle(req: NextApiRequest, res: NextApiResponse) { - // Check Authentication - const currentUser: any = await getSessionUser(req, res); - if (!currentUser) { - return res.status(401).json({ message: "Not authenticated" }); - } - - const teamId = req.query.teamId?.toString(); - if (teamId === undefined) { - return res.status(400).json({ message: "Missing teamId" }); - } - - const hasAccess = await hasTeamAccess(currentUser, teamId); - if (!hasAccess) { - return res.status(403).json({ message: "Not authorized" }); - } - - const userId = req.query.userId?.toString(); - if (userId === undefined) { - return res.status(400).json({ message: "Missing userId" }); - } - - // PATCH /api/v1/teams/[teamId]/members/[userId] - // Update a member's role - - if (req.method === "PATCH") { - const hasOwnerOrAdminAccess = await isAdminOrOwner(currentUser, teamId); - if (!hasOwnerOrAdminAccess) { - return res.status(403).json({ message: "You are not allowed to update member's role in this team" }); - } - - if (userId === currentUser.id) { - return res.status(403).json({ message: "You cannot update your own role in this team" }); - } - - const { role } = req.body; - const updatedMembership = await prisma.membership.update({ - where: { - userId_teamId: { - userId, - teamId, - }, - }, - data: { - role, - }, - }); - return res.json(updatedMembership); - } - - // DELETE /api/v1/teams/[teamId]/members/[userId] - // Remove a member from a team - if (req.method === "DELETE") { - // check if currentUser is owner of the team - const membership = await prisma.membership.findUnique({ - where: { - userId_teamId: { - userId: currentUser.id, - teamId, - }, - }, - }); - if (membership?.role !== "owner" && membership?.role !== "admin") { - return res.status(403).json({ message: "You are not allowed to delete member froms this team" }); - } else if (membership?.role === "owner" && userId === currentUser.id) { - return res.status(403).json({ message: "You cannot delete yourself from this team" }); - } - - //delete membership - const membershipToDelete = await prisma.membership.delete({ - where: { - userId_teamId: { - userId, - teamId, - }, - }, - }); - return res.json(membershipToDelete); - } - - // Unknown HTTP Method - else { - throw new Error(`The HTTP ${req.method} method is not supported by this route.`); - } -} diff --git a/apps/web/pages/api/v1/teams/[teamId]/transfer-ownership/index.ts b/apps/web/pages/api/v1/teams/[teamId]/transfer-ownership/index.ts deleted file mode 100644 index 9d4417a17a..0000000000 --- a/apps/web/pages/api/v1/teams/[teamId]/transfer-ownership/index.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { getSessionUser, hasTeamAccess, isOwner } from "@/app/lib/api/apiHelper"; -import { prisma } from "@formbricks/database"; -import type { NextApiRequest, NextApiResponse } from "next"; - -export default async function handle(req: NextApiRequest, res: NextApiResponse) { - // Check Authentication - const currentUser: any = await getSessionUser(req, res); - if (!currentUser) { - return res.status(401).json({ message: "Not authenticated" }); - } - - const teamId = req.query.teamId?.toString(); - if (teamId === undefined) { - return res.status(400).json({ message: "Missing teamId" }); - } - - const hasAccess = await hasTeamAccess(currentUser, teamId); - if (!hasAccess) { - return res.status(403).json({ message: "Not authorized" }); - } - - /** - * Transfer ownership of a team to another member - * @route PATCH /api/v1/teams/{teamId}/transfer-ownership - * @param {string} teamId - The id of the team to transfer ownership of - * @param {string} userId - The id of the user to transfer ownership to - * @returns {object} - The updated membership of the new owner and the updated membership of the old owner - * - */ - if (req.method === "PATCH") { - const { userId: newOwnerId } = req.body; - - const hasOwnerAccess = await isOwner(currentUser, teamId); - - if (!hasOwnerAccess) { - return res.status(403).json({ message: "You must be the owner of this team to transfer ownership" }); - } - - if (newOwnerId === currentUser.id) { - return res.status(403).json({ message: "You cannot transfer ownership to yourself" }); - } - - const isMember = await prisma.membership.findFirst({ - where: { - userId: newOwnerId, - teamId, - }, - }); - if (!isMember) { - return res.status(403).json({ message: "The new owner must be a member of the team" }); - } - - try { - await prisma.$transaction([ - prisma.membership.update({ - where: { - userId_teamId: { - teamId, - userId: currentUser.id, - }, - }, - data: { - role: "admin", - }, - }), - prisma.membership.update({ - where: { - userId_teamId: { - teamId, - userId: newOwnerId, - }, - }, - data: { - role: "owner", - }, - }), - ]); - } catch (error) { - return res.status(500).json({ message: "Something went wrong" }); - } - - return res.json({ message: "Ownership transferred successfully" }); - } -} diff --git a/apps/web/playwright/js.spec.ts b/apps/web/playwright/js.spec.ts new file mode 100644 index 0000000000..0e1b25f010 --- /dev/null +++ b/apps/web/playwright/js.spec.ts @@ -0,0 +1,134 @@ +import { expect, test } from "@playwright/test"; + +import { login, replaceEnvironmentIdInHtml, signUpAndLogin, skipOnboarding } from "./utils/helper"; +import { users } from "./utils/mock"; + +test.describe("JS Package Test", async () => { + const { name, email, password } = users.js[0]; + let environmentId: string; + test.describe.configure({ mode: "serial" }); + + test("Admin creates an In-App Survey", async ({ page }) => { + await signUpAndLogin(page, name, email, password); + await skipOnboarding(page); + + await page.waitForURL(/\/environments\/[^/]+\/surveys/); + + await page + .getByText("Product ExperienceProduct Market Fit (Superhuman)Measure PMF by assessing how") + .isVisible(); + + await page + .getByText("Product ExperienceProduct Market Fit (Superhuman)Measure PMF by assessing how") + .click(); + await page.getByRole("button", { name: "Settings", exact: true }).click(); + await page.locator("label").filter({ hasText: "In-App SurveyEmbed a survey" }).click(); + await page + .locator("div") + .filter({ hasText: /^Survey TriggerChoose the actions which trigger the survey\.$/ }) + .nth(1) + .click(); + await page.getByRole("combobox").click(); + await page.getByLabel("New Session").click(); + await page.getByRole("button", { name: "Publish" }).click(); + + environmentId = + /\/environments\/([^/]+)\/surveys/.exec(page.url())?.[1] ?? + (() => { + throw new Error("Unable to parse environmentId from URL"); + })(); + + await page.waitForURL(/\/environments\/[^/]+\/surveys\/[^/]+\/summary/); + }); + + test("JS Display Survey on Page", async ({ page }) => { + let currentDir = process.cwd(); + let htmlFilePath = currentDir + "/packages/js/index.html"; + + let htmlFile = replaceEnvironmentIdInHtml(htmlFilePath, environmentId); + await page.goto(htmlFile); + + // Formbricks In App Sync has happened + const syncApi = await page.waitForResponse((response) => response.url().includes("/in-app/sync")); + expect(syncApi.status()).toBe(200); + + // Formbricks Modal exists in the DOM + await expect(page.locator("#formbricks-modal-container")).toHaveCount(1); + + const displayApi = await page.waitForResponse((response) => response.url().includes("/display")); + expect(displayApi.status()).toBe(200); + + // Formbricks Modal is visible + await expect(page.getByRole("link", { name: "Powered by Formbricks" })).toBeVisible(); + }); + + test("Admin checks Display", async ({ page }) => { + await login(page, email, password); + + await page.locator("li").filter({ hasText: "In-Product SurveyProduct" }).getByRole("link").click(); + + (await page.waitForSelector("text=Responses")).isVisible(); + + // Survey should have 1 Display + await expect(page.getByText("Displays1")).toBeVisible(); + + // Survey should have 0 Responses + await expect(page.getByRole("button", { name: "Responses0% -" })).toBeVisible(); + }); + + test("JS submits Response to Survey", async ({ page }) => { + let currentDir = process.cwd(); + let htmlFilePath = currentDir + "/packages/js/index.html"; + + let htmlFile = "file:///" + htmlFilePath; + + await page.goto(htmlFile); + + // Formbricks In App Sync has happened + const syncApi = await page.waitForResponse((response) => response.url().includes("/in-app/sync")); + expect(syncApi.status()).toBe(200); + + // Formbricks Modal exists in the DOM + await expect(page.locator("#formbricks-modal-container")).toHaveCount(1); + + // Formbricks Modal is visible + await expect(page.getByRole("link", { name: "Powered by Formbricks" })).toBeVisible(); + + // Fill the Survey + await page.getByRole("button", { name: "Happy to help!" }).click(); + await page.locator("label").filter({ hasText: "Somewhat disappointed" }).click(); + await page.getByRole("button", { name: "Next" }).click(); + await page.locator("label").filter({ hasText: "Founder" }).click(); + await page.getByRole("button", { name: "Next" }).click(); + await page.getByLabel("").fill("People who believe that PMF is necessary"); + await page.getByRole("button", { name: "Next" }).click(); + await page.getByLabel("").fill("Much higher response rates!"); + await page.getByRole("button", { name: "Next" }).click(); + await page.getByLabel("Please be as specific as").fill("Make this end to end test pass!"); + await page.getByRole("button", { name: "Finish" }).click(); + await page.getByText("Thank you!").click(); + + // Formbricks Modal is not visible + await expect(page.getByText("Powered by Formbricks")).not.toBeVisible({ timeout: 10000 }); + }); + + test("Admin validates Response", async ({ page }) => { + await login(page, email, password); + + await page.locator("li").filter({ hasText: "In-Product SurveyProduct" }).getByRole("link").click(); + + (await page.waitForSelector("text=Responses")).isVisible(); + + // Survey should have 2 Displays + await expect(page.getByText("Displays2")).toBeVisible(); + // Survey should have 1 Response + await expect(page.getByRole("button", { name: "Responses50%" })).toBeVisible(); + await expect(page.getByText("1 responses", { exact: true }).first()).toBeVisible(); + await expect(page.getByText("Clickthrough Rate (CTR)100%")).toBeVisible(); + await expect(page.getByText("Somewhat disappointed100%")).toBeVisible(); + await expect(page.getByText("Founder100%")).toBeVisible(); + await expect(page.getByText("People who believe that PMF").first()).toBeVisible(); + await expect(page.getByText("Much higher response rates!").first()).toBeVisible(); + await expect(page.getByText("Make this end to end test").first()).toBeVisible(); + }); +}); diff --git a/apps/web/playwright/onboarding.spec.ts b/apps/web/playwright/onboarding.spec.ts new file mode 100644 index 0000000000..a26a0fa686 --- /dev/null +++ b/apps/web/playwright/onboarding.spec.ts @@ -0,0 +1,50 @@ +import { expect, test } from "@playwright/test"; + +import { signUpAndLogin } from "./utils/helper"; +import { teams, users } from "./utils/mock"; + +const { role, productName, useCase } = teams.onboarding[0]; + +test.describe("Onboarding Flow Test", async () => { + test("Step by Step", async ({ page }) => { + const { name, email, password } = users.onboarding[0]; + await signUpAndLogin(page, name, email, password); + await page.waitForURL("/onboarding"); + await expect(page).toHaveURL("/onboarding"); + + await page.getByRole("button", { name: "Begin (1 min)" }).click(); + await page.getByLabel(role).check(); + await page.getByRole("button", { name: "Next" }).click(); + + await expect(page.getByLabel(useCase)).toBeVisible(); + await page.getByLabel(useCase).check(); + await page.getByRole("button", { name: "Next" }).click(); + + await expect(page.getByPlaceholder("e.g. Formbricks")).toBeVisible(); + await page.getByPlaceholder("e.g. Formbricks").fill(productName); + + await page.locator("#color-picker").click(); + await page.getByLabel("Hue").click(); + + await page.locator("div").filter({ hasText: "Create your team's product." }).nth(1).click(); + await page.getByRole("button", { name: "Done" }).click(); + + await page.waitForURL(/\/environments\/[^/]+\/surveys/); + await expect(page).toHaveURL(/\/environments\/[^/]+\/surveys/); + await expect(page.getByText(productName)).toBeVisible(); + }); + + test("Skip", async ({ page }) => { + const { name, email, password } = users.onboarding[1]; + await signUpAndLogin(page, name, email, password); + await page.waitForURL("/onboarding"); + await expect(page).toHaveURL("/onboarding"); + + await page.getByRole("button", { name: "I'll do it later" }).click(); + await page.getByRole("button", { name: "I'll do it later" }).click(); + + await page.waitForURL(/\/environments\/[^/]+\/surveys/); + await expect(page).toHaveURL(/\/environments\/[^/]+\/surveys/); + await expect(page.getByText("My Product")).toBeVisible(); + }); +}); diff --git a/apps/web/playwright/signup.spec.ts b/apps/web/playwright/signup.spec.ts new file mode 100644 index 0000000000..bbc8bced17 --- /dev/null +++ b/apps/web/playwright/signup.spec.ts @@ -0,0 +1,68 @@ +import { expect, test } from "@playwright/test"; + +import { users } from "./utils/mock"; + +const { name, email, password } = users.signup[0]; + +test.describe("Email Signup Flow Test", async () => { + test.describe.configure({ mode: "serial" }); + test.beforeEach(async ({ page }) => { + await page.goto("/auth/signup"); + await page.getByText("Continue with Email").click(); + }); + + test("Valid User", async ({ page }) => { + await page.fill('input[name="name"]', name); + await page.getByPlaceholder("Full Name").press("Tab"); + await page.fill('input[name="email"]', email); + await page.getByPlaceholder("work@email.com").press("Tab"); + await page.fill('input[name="password"]', password); + await page.press('input[name="password"]', "Enter"); + await page.waitForURL("/auth/signup-without-verification-success"); + await expect(page).toHaveURL("/auth/signup-without-verification-success"); + }); + + test("Email is taken", async ({ page }) => { + await page.fill('input[name="name"]', name); + await page.getByPlaceholder("Full Name").press("Tab"); + await page.fill('input[name="email"]', email); + await page.getByPlaceholder("work@email.com").press("Tab"); + await page.fill('input[name="password"]', password); + await page.press('input[name="password"]', "Enter"); + let alertMessage = "user with this email address already exists"; + await (await page.waitForSelector(`text=${alertMessage}`)).isVisible(); + }); + + test("No Name", async ({ page }) => { + await page.fill('input[name="name"]', ""); + await page.getByPlaceholder("Full Name").press("Tab"); + await page.fill('input[name="email"]', email); + await page.getByPlaceholder("work@email.com").press("Tab"); + await page.fill('input[name="password"]', password); + await page.press('input[name="password"]', "Enter"); + const button = page.getByText("Continue with Email"); + await expect(button).toBeDisabled(); + }); + + test("Invalid Email", async ({ page }) => { + await page.fill('input[name="name"]', name); + await page.getByPlaceholder("Full Name").press("Tab"); + await page.fill('input[name="email"]', "invalid"); + await page.getByPlaceholder("work@email.com").press("Tab"); + await page.fill('input[name="password"]', password); + await page.press('input[name="password"]', "Enter"); + const button = page.getByText("Continue with Email"); + await expect(button).toBeDisabled(); + }); + + test("Invalid Password", async ({ page }) => { + await page.fill('input[name="name"]', name); + await page.getByPlaceholder("Full Name").press("Tab"); + await page.fill('input[name="email"]', email); + await page.getByPlaceholder("work@email.com").press("Tab"); + await page.fill('input[name="password"]', "invalid"); + await page.press('input[name="password"]', "Enter"); + const button = page.getByText("Continue with Email"); + await expect(button).toBeDisabled(); + }); +}); diff --git a/apps/web/playwright/survey.spec.ts b/apps/web/playwright/survey.spec.ts new file mode 100644 index 0000000000..67a81b9d63 --- /dev/null +++ b/apps/web/playwright/survey.spec.ts @@ -0,0 +1,258 @@ +import { surveys, users } from "@/playwright/utils/mock"; +import { expect, test } from "@playwright/test"; +import path from "path"; + +import { signUpAndLogin, skipOnboarding } from "./utils/helper"; + +test.describe("Survey Create & Submit Response", async () => { + test.describe.configure({ mode: "serial" }); + let url: string | null; + const { name, email, password } = users.survey[0]; + let addQuestion = "Add QuestionAdd a new question to your survey"; + + test("Create Survey", async ({ page }) => { + await signUpAndLogin(page, name, email, password); + await skipOnboarding(page); + + await page.getByRole("heading", { name: "Start from Scratch" }).click(); + + // Welcome Card + await expect(page.locator("#welcome-toggle")).toBeVisible(); + await page.getByText("Welcome Card").click(); + await page.locator("#welcome-toggle").check(); + await page.getByLabel("Headline").fill(surveys.createAndSubmit.welcomeCard.headline); + await page + .locator("form") + .getByText("Thanks for providing your") + .fill(surveys.createAndSubmit.welcomeCard.description); + await page.getByText("Welcome CardEnabled").click(); + + // Open Text Question + await page.getByRole("button", { name: "1 What would you like to know" }).click(); + await page.getByLabel("Question").fill(surveys.createAndSubmit.openTextQuestion.question); + await page.getByLabel("Description").fill(surveys.createAndSubmit.openTextQuestion.description); + await page.getByLabel("Placeholder").fill(surveys.createAndSubmit.openTextQuestion.placeholder); + await page.getByRole("button", { name: surveys.createAndSubmit.openTextQuestion.question }).click(); + + // Single Select Question + await page + .locator("div") + .filter({ hasText: new RegExp(`^${addQuestion}$`) }) + .nth(1) + .click(); + await page.getByRole("button", { name: "Single-Select" }).click(); + await page.getByLabel("Question").fill(surveys.createAndSubmit.singleSelectQuestion.question); + await page.getByLabel("Description").fill(surveys.createAndSubmit.singleSelectQuestion.description); + await page.getByPlaceholder("Option 1").fill(surveys.createAndSubmit.singleSelectQuestion.options[0]); + await page.getByPlaceholder("Option 2").fill(surveys.createAndSubmit.singleSelectQuestion.options[1]); + await page.getByRole("button", { name: 'Add "Other"', exact: true }).click(); + + // Multi Select Question + await page + .locator("div") + .filter({ hasText: new RegExp(`^${addQuestion}$`) }) + .nth(1) + .click(); + await page.getByRole("button", { name: "Multi-Select" }).click(); + await page.getByLabel("Question").fill(surveys.createAndSubmit.multiSelectQuestion.question); + await page.getByRole("button", { name: "Add Description", exact: true }).click(); + await page.getByLabel("Description").fill(surveys.createAndSubmit.multiSelectQuestion.description); + await page.getByPlaceholder("Option 1").fill(surveys.createAndSubmit.multiSelectQuestion.options[0]); + await page.getByPlaceholder("Option 2").fill(surveys.createAndSubmit.multiSelectQuestion.options[1]); + await page.getByPlaceholder("Option 3").fill(surveys.createAndSubmit.multiSelectQuestion.options[2]); + + // Rating Question + await page + .locator("div") + .filter({ hasText: new RegExp(`^${addQuestion}$`) }) + .nth(1) + .click(); + await page.getByRole("button", { name: "Rating" }).click(); + await page.getByLabel("Question").fill(surveys.createAndSubmit.ratingQuestion.question); + await page.getByLabel("Scale").fill(surveys.createAndSubmit.ratingQuestion.description); + await page.getByPlaceholder("Not good").fill(surveys.createAndSubmit.ratingQuestion.lowLabel); + await page.getByPlaceholder("Very satisfied").fill(surveys.createAndSubmit.ratingQuestion.highLabel); + + // NPS Question + await page + .locator("div") + .filter({ hasText: new RegExp(`^${addQuestion}$`) }) + .nth(1) + .click(); + await page.getByRole("button", { name: "Net Promoter Score (NPS)" }).click(); + await page.getByLabel("Question").fill(surveys.createAndSubmit.npsQuestion.question); + await page.getByLabel("Lower label").fill(surveys.createAndSubmit.npsQuestion.lowLabel); + await page + .locator("div") + .filter({ hasText: /^Upper label$/ }) + .locator("#subheader") + .fill(surveys.createAndSubmit.npsQuestion.highLabel); + + // CTA Question + await page + .locator("div") + .filter({ hasText: new RegExp(`^${addQuestion}$`) }) + .nth(1) + .click(); + await page.getByRole("button", { name: "Call-to-Action" }).click(); + await page.getByLabel("Question").fill(surveys.createAndSubmit.ctaQuestion.question); + await page.getByPlaceholder("Finish").fill(surveys.createAndSubmit.ctaQuestion.buttonLabel); + + // Consent Question + await page + .locator("div") + .filter({ hasText: new RegExp(`^${addQuestion}$`) }) + .nth(1) + .click(); + await page.getByRole("button", { name: "Consent" }).click(); + await page.getByLabel("Question").fill(surveys.createAndSubmit.consentQuestion.question); + await page + .getByPlaceholder("I agree to the terms and") + .fill(surveys.createAndSubmit.consentQuestion.checkboxLabel); + + // Picture Select Question + await page + .locator("div") + .filter({ hasText: new RegExp(`^${addQuestion}$`) }) + .nth(1) + .click(); + await page.getByRole("button", { name: "Picture Selection" }).click(); + await page.getByLabel("Question").fill(surveys.createAndSubmit.pictureSelectQuestion.question); + await page.getByLabel("Description").fill(surveys.createAndSubmit.pictureSelectQuestion.description); + + // File Upload Question + await page + .locator("div") + .filter({ hasText: new RegExp(`^${addQuestion}$`) }) + .nth(1) + .click(); + await page.getByRole("button", { name: "File Upload" }).click(); + await page.getByLabel("Question").fill(surveys.createAndSubmit.fileUploadQuestion.question); + + // Thank You Card + await page + .locator("div") + .filter({ hasText: /^Thank You CardShown$/ }) + .nth(1) + .click(); + await page.getByLabel("Headline").fill(surveys.createAndSubmit.thankYouCard.headline); + await page.getByLabel("Description").fill(surveys.createAndSubmit.thankYouCard.description); + + // Save & Publish Survey + await page.getByRole("button", { name: "Continue to Settings" }).click(); + await page.getByRole("button", { name: "Publish" }).click(); + + // Get URL + await page.waitForURL(/\/environments\/[^/]+\/surveys\/[^/]+\/summary$/); + url = await page + .locator("div") + .filter({ hasText: /^http:\/\/localhost:3000\/s\/[A-Za-z0-9]+$/ }) + .innerText(); + }); + + test("Submit Survey Response", async ({ page }) => { + await page.goto(url!); + await page.waitForURL(/\/s\/[A-Za-z0-9]+$/); + + // Welcome Card + await expect(page.getByText(surveys.createAndSubmit.welcomeCard.headline)).toBeVisible(); + await expect(page.getByText(surveys.createAndSubmit.welcomeCard.description)).toBeVisible(); + await page.getByRole("button", { name: "Next" }).click(); + + // Open Text Question + await expect(page.getByText(surveys.createAndSubmit.openTextQuestion.question)).toBeVisible(); + await expect(page.getByText(surveys.createAndSubmit.openTextQuestion.description)).toBeVisible(); + await expect(page.getByPlaceholder(surveys.createAndSubmit.openTextQuestion.placeholder)).toBeVisible(); + await page + .getByPlaceholder(surveys.createAndSubmit.openTextQuestion.placeholder) + .fill("This is my Open Text answer"); + await page.getByRole("button", { name: "Next" }).click(); + + // Single Select Question + await expect(page.getByText(surveys.createAndSubmit.singleSelectQuestion.question)).toBeVisible(); + await expect(page.getByText(surveys.createAndSubmit.singleSelectQuestion.description)).toBeVisible(); + for (let i = 0; i < surveys.createAndSubmit.singleSelectQuestion.options.length; i++) { + await expect(page.getByText(surveys.createAndSubmit.singleSelectQuestion.options[i])).toBeVisible(); + } + await expect(page.getByText("Other")).toBeVisible(); + await expect(page.getByRole("button", { name: "Next" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Back" })).toBeVisible(); + await page.getByText(surveys.createAndSubmit.singleSelectQuestion.options[0]).click(); + await page.getByRole("button", { name: "Next" }).click(); + + // Multi Select Question + await expect(page.getByText(surveys.createAndSubmit.multiSelectQuestion.question)).toBeVisible(); + await expect(page.getByText(surveys.createAndSubmit.multiSelectQuestion.description)).toBeVisible(); + for (let i = 0; i < surveys.createAndSubmit.multiSelectQuestion.options.length; i++) { + await expect(page.getByText(surveys.createAndSubmit.multiSelectQuestion.options[i])).toBeVisible(); + } + await expect(page.getByRole("button", { name: "Next" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Back" })).toBeVisible(); + await page.getByText(surveys.createAndSubmit.multiSelectQuestion.options[0]).click(); + await page.getByText(surveys.createAndSubmit.multiSelectQuestion.options[1]).click(); + await page.getByRole("button", { name: "Next" }).click(); + + // Rating Question + await expect(page.getByText(surveys.createAndSubmit.ratingQuestion.question)).toBeVisible(); + await expect(page.getByText(surveys.createAndSubmit.ratingQuestion.description)).toBeVisible(); + await expect(page.getByText(surveys.createAndSubmit.ratingQuestion.lowLabel)).toBeVisible(); + await expect(page.getByText(surveys.createAndSubmit.ratingQuestion.highLabel)).toBeVisible(); + expect(await page.getByRole("group", { name: "Choices" }).locator("label").count()).toBe(5); + await expect(page.getByRole("button", { name: "Next" })).not.toBeVisible(); + await expect(page.getByRole("button", { name: "Back" })).toBeVisible(); + await page.getByLabel("").nth(3).click(); + + // NPS Question + await expect(page.getByText(surveys.createAndSubmit.npsQuestion.question)).toBeVisible(); + await expect(page.getByText(surveys.createAndSubmit.npsQuestion.lowLabel)).toBeVisible(); + await expect(page.getByText(surveys.createAndSubmit.npsQuestion.highLabel)).toBeVisible(); + await expect(page.getByRole("button", { name: "Next" })).not.toBeVisible(); + await expect(page.getByRole("button", { name: "Back" })).toBeVisible(); + + for (let i = 0; i < 11; i++) { + await expect(page.getByText(`${i}`, { exact: true })).toBeVisible(); + } + await page.getByText("8").click(); + + // CTA Question + await expect(page.getByText(surveys.createAndSubmit.ctaQuestion.question)).toBeVisible(); + await expect( + page.getByRole("button", { name: surveys.createAndSubmit.ctaQuestion.buttonLabel }) + ).toBeVisible(); + await page.getByRole("button", { name: surveys.createAndSubmit.ctaQuestion.buttonLabel }).click(); + + // Consent Question + await expect(page.getByText(surveys.createAndSubmit.consentQuestion.question)).toBeVisible(); + await expect(page.getByText(surveys.createAndSubmit.consentQuestion.checkboxLabel)).toBeVisible(); + await expect(page.getByRole("button", { name: "Next" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Back" })).toBeVisible(); + await page.getByText(surveys.createAndSubmit.consentQuestion.checkboxLabel).check(); + await page.getByRole("button", { name: "Next" }).click(); + + // Picture Select Question + await expect(page.getByText(surveys.createAndSubmit.pictureSelectQuestion.question)).toBeVisible(); + await expect(page.getByText(surveys.createAndSubmit.pictureSelectQuestion.description)).toBeVisible(); + await expect(page.getByRole("button", { name: "Next" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Back" })).toBeVisible(); + await expect(page.getByRole("img", { name: "puppy-1-small.jpg" })).toBeVisible(); + await expect(page.getByRole("img", { name: "puppy-2-small.jpg" })).toBeVisible(); + await page.getByRole("img", { name: "puppy-1-small.jpg" }).click(); + await page.getByRole("button", { name: "Next" }).click(); + + // File Upload Question + await expect(page.getByText(surveys.createAndSubmit.fileUploadQuestion.question)).toBeVisible(); + await expect(page.getByRole("button", { name: "Finish" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Back" })).toBeVisible(); + await expect( + page.locator("label").filter({ hasText: "Click or drag to upload files." }).locator("div").nth(0) + ).toBeVisible(); + await page.locator("input[type=file]").setInputFiles(path.join(__dirname, "survey.spec.ts")); + await page.getByText("Uploading...").waitFor({ state: "hidden" }); + + await page.getByRole("button", { name: "Finish" }).click(); + + // Thank You Card + await expect(page.getByText(surveys.createAndSubmit.thankYouCard.headline)).toBeVisible(); + await expect(page.getByText(surveys.createAndSubmit.thankYouCard.description)).toBeVisible(); + }); +}); diff --git a/apps/web/playwright/utils/helper.ts b/apps/web/playwright/utils/helper.ts new file mode 100644 index 0000000000..b9f1b19596 --- /dev/null +++ b/apps/web/playwright/utils/helper.ts @@ -0,0 +1,53 @@ +import { expect } from "@playwright/test"; +import { readFileSync, writeFileSync } from "fs"; +import { Page } from "playwright"; + +export const signUpAndLogin = async ( + page: Page, + name: string, + email: string, + password: string +): Promise => { + await page.goto("/auth/login"); + await page.getByRole("link", { name: "Create an account" }).click(); + await page.getByRole("button", { name: "Continue with Email" }).click(); + await page.getByPlaceholder("Full Name").fill(name); + await page.getByPlaceholder("Full Name").press("Tab"); + await page.getByPlaceholder("work@email.com").fill(email); + await page.getByPlaceholder("work@email.com").press("Tab"); + await page.getByPlaceholder("*******").fill(password); + await page.press('input[name="password"]', "Enter"); + await page.getByRole("link", { name: "Login" }).click(); + await page.getByRole("button", { name: "Login with Email" }).click(); + await page.getByPlaceholder("work@email.com").fill(email); + await page.getByPlaceholder("*******").click(); + await page.getByPlaceholder("*******").fill(password); + await page.getByRole("button", { name: "Login with Email" }).click(); +}; + +export const login = async (page: Page, email: string, password: string): Promise => { + await page.goto("/auth/login"); + await page.getByRole("button", { name: "Login with Email" }).click(); + await page.getByPlaceholder("work@email.com").fill(email); + await page.getByPlaceholder("*******").click(); + await page.getByPlaceholder("*******").fill(password); + await page.getByRole("button", { name: "Login with Email" }).click(); +}; + +export const skipOnboarding = async (page: Page): Promise => { + await page.waitForURL("/onboarding"); + await expect(page).toHaveURL("/onboarding"); + await page.getByRole("button", { name: "I'll do it later" }).click(); + await page.getByRole("button", { name: "I'll do it later" }).click(); + await page.waitForURL(/\/environments\/[^/]+\/surveys/); + await expect(page).toHaveURL(/\/environments\/[^/]+\/surveys/); + await expect(page.getByText("My Product")).toBeVisible(); +}; + +export const replaceEnvironmentIdInHtml = (filePath: string, environmentId: string): string => { + let htmlContent = readFileSync(filePath, "utf-8"); + htmlContent = htmlContent.replace(/environmentId: ".*?"/, `environmentId: "${environmentId}"`); + + writeFileSync(filePath, htmlContent); + return "file:///" + filePath; +}; diff --git a/apps/web/playwright/utils/mock.ts b/apps/web/playwright/utils/mock.ts new file mode 100644 index 0000000000..58edb39256 --- /dev/null +++ b/apps/web/playwright/utils/mock.ts @@ -0,0 +1,99 @@ +export const users = { + signup: [ + { + name: "SignUp Flow User 1", + email: "signup1@formbricks.com", + password: "eN791hZ7wNr9IAscf@", + }, + ], + onboarding: [ + { + name: "Onboarding User 1", + email: "onboarding1@formbricks.com", + password: "iHalLonErFGK$X901R0", + }, + { + name: "Onboarding User 2", + email: "onboarding2@formbricks.com", + password: "231Xh7D&dM8u75EjIYV", + }, + ], + survey: [ + { + name: "Survey User 1", + email: "survey1@formbricks.com", + password: "Y1I*EpURUSb32j5XijP", + }, + ], + js: [ + { + name: "JS User 1", + email: "js1@formbricks.com", + password: "XpP%X9UU3efj8vJa", + }, + ], +}; + +export const teams = { + onboarding: [ + { + role: "Founder", + useCase: "Increase conversion", + productName: "Formbricks E2E Test Suite", + }, + ], +}; + +export const surveys = { + createAndSubmit: { + welcomeCard: { + headline: "Welcome to My Testing Survey Welcome Card!", + description: "This is the description of my Welcome Card!", + }, + openTextQuestion: { + question: "This is my Open Text Question", + description: "This is my Open Text Description", + placeholder: "This is my Placeholder", + }, + singleSelectQuestion: { + question: "This is my Single Select Question", + description: "This is my Single Select Description", + options: ["Option 1", "Option 2"], + }, + multiSelectQuestion: { + question: "This is my Multi Select Question", + description: "This is Multi Select Description", + options: ["Option 1", "Option 2", "Option 3"], + }, + ratingQuestion: { + question: "This is my Rating Question", + description: "This is Rating Description", + lowLabel: "My Lower Label", + highLabel: "My Upper Label", + }, + npsQuestion: { + question: "This is my NPS Question", + lowLabel: "My Lower Label", + highLabel: "My Upper Label", + }, + ctaQuestion: { + question: "This is my CTA Question", + buttonLabel: "My Button Label", + }, + consentQuestion: { + question: "This is my Consent Question", + checkboxLabel: "My Checkbox Label", + }, + pictureSelectQuestion: { + question: "This is my Picture Select Question", + description: "This is Picture Select Description", + }, + fileUploadQuestion: { + question: "This is my File Upload Question", + }, + thankYouCard: { + headline: "This is my Thank You Card Headline!", + description: "This is my Thank you Card Description!", + }, + }, +}; diff --git a/apps/web/public/animated-bgs/4K/10_4k.mp4 b/apps/web/public/animated-bgs/4K/10_4k.mp4 new file mode 100644 index 0000000000..0cc1447d93 Binary files /dev/null and b/apps/web/public/animated-bgs/4K/10_4k.mp4 differ diff --git a/apps/web/public/animated-bgs/4K/11_4k.mp4 b/apps/web/public/animated-bgs/4K/11_4k.mp4 new file mode 100644 index 0000000000..6774ee5541 Binary files /dev/null and b/apps/web/public/animated-bgs/4K/11_4k.mp4 differ diff --git a/apps/web/public/animated-bgs/4K/12_4k.mp4 b/apps/web/public/animated-bgs/4K/12_4k.mp4 new file mode 100644 index 0000000000..c6e3c9e8dc Binary files /dev/null and b/apps/web/public/animated-bgs/4K/12_4k.mp4 differ diff --git a/apps/web/public/animated-bgs/4K/13_4k.mp4 b/apps/web/public/animated-bgs/4K/13_4k.mp4 new file mode 100644 index 0000000000..4a3e4eed33 Binary files /dev/null and b/apps/web/public/animated-bgs/4K/13_4k.mp4 differ diff --git a/apps/web/public/animated-bgs/4K/14_4k.mp4 b/apps/web/public/animated-bgs/4K/14_4k.mp4 new file mode 100644 index 0000000000..29dc0dfe31 Binary files /dev/null and b/apps/web/public/animated-bgs/4K/14_4k.mp4 differ diff --git a/apps/web/public/animated-bgs/4K/15_4k.mp4 b/apps/web/public/animated-bgs/4K/15_4k.mp4 new file mode 100644 index 0000000000..aefb4d8701 Binary files /dev/null and b/apps/web/public/animated-bgs/4K/15_4k.mp4 differ diff --git a/apps/web/public/animated-bgs/4K/16_4k.mp4 b/apps/web/public/animated-bgs/4K/16_4k.mp4 new file mode 100644 index 0000000000..594811833f Binary files /dev/null and b/apps/web/public/animated-bgs/4K/16_4k.mp4 differ diff --git a/apps/web/public/animated-bgs/4K/17_4k.mp4 b/apps/web/public/animated-bgs/4K/17_4k.mp4 new file mode 100644 index 0000000000..9b0ed4c483 Binary files /dev/null and b/apps/web/public/animated-bgs/4K/17_4k.mp4 differ diff --git a/apps/web/public/animated-bgs/4K/18_4k.mp4 b/apps/web/public/animated-bgs/4K/18_4k.mp4 new file mode 100644 index 0000000000..7dac8b7080 Binary files /dev/null and b/apps/web/public/animated-bgs/4K/18_4k.mp4 differ diff --git a/apps/web/public/animated-bgs/4K/19_4k.mp4 b/apps/web/public/animated-bgs/4K/19_4k.mp4 new file mode 100644 index 0000000000..f760757aa2 Binary files /dev/null and b/apps/web/public/animated-bgs/4K/19_4k.mp4 differ diff --git a/apps/web/public/animated-bgs/4K/1_4k.mp4 b/apps/web/public/animated-bgs/4K/1_4k.mp4 new file mode 100644 index 0000000000..bee93610e5 Binary files /dev/null and b/apps/web/public/animated-bgs/4K/1_4k.mp4 differ diff --git a/apps/web/public/animated-bgs/4K/20_4k.mp4 b/apps/web/public/animated-bgs/4K/20_4k.mp4 new file mode 100644 index 0000000000..639f74b4d7 Binary files /dev/null and b/apps/web/public/animated-bgs/4K/20_4k.mp4 differ diff --git a/apps/web/public/animated-bgs/4K/21_4k.mp4 b/apps/web/public/animated-bgs/4K/21_4k.mp4 new file mode 100644 index 0000000000..e5a3a530f1 Binary files /dev/null and b/apps/web/public/animated-bgs/4K/21_4k.mp4 differ diff --git a/apps/web/public/animated-bgs/4K/22_4k.mp4 b/apps/web/public/animated-bgs/4K/22_4k.mp4 new file mode 100644 index 0000000000..133378eb19 Binary files /dev/null and b/apps/web/public/animated-bgs/4K/22_4k.mp4 differ diff --git a/apps/web/public/animated-bgs/4K/23_4k.mp4 b/apps/web/public/animated-bgs/4K/23_4k.mp4 new file mode 100644 index 0000000000..62c263b29c Binary files /dev/null and b/apps/web/public/animated-bgs/4K/23_4k.mp4 differ diff --git a/apps/web/public/animated-bgs/4K/24_4k.mp4 b/apps/web/public/animated-bgs/4K/24_4k.mp4 new file mode 100644 index 0000000000..b104600ea3 Binary files /dev/null and b/apps/web/public/animated-bgs/4K/24_4k.mp4 differ diff --git a/apps/web/public/animated-bgs/4K/25_4k.mp4 b/apps/web/public/animated-bgs/4K/25_4k.mp4 new file mode 100644 index 0000000000..f41c368c74 Binary files /dev/null and b/apps/web/public/animated-bgs/4K/25_4k.mp4 differ diff --git a/apps/web/public/animated-bgs/4K/26_4k.mp4 b/apps/web/public/animated-bgs/4K/26_4k.mp4 new file mode 100644 index 0000000000..1c7c2f6fc9 Binary files /dev/null and b/apps/web/public/animated-bgs/4K/26_4k.mp4 differ diff --git a/apps/web/public/animated-bgs/4K/27_4k.mp4 b/apps/web/public/animated-bgs/4K/27_4k.mp4 new file mode 100644 index 0000000000..ca7dcf4baf Binary files /dev/null and b/apps/web/public/animated-bgs/4K/27_4k.mp4 differ diff --git a/apps/web/public/animated-bgs/4K/28_4k.mp4 b/apps/web/public/animated-bgs/4K/28_4k.mp4 new file mode 100644 index 0000000000..190a030be9 Binary files /dev/null and b/apps/web/public/animated-bgs/4K/28_4k.mp4 differ diff --git a/apps/web/public/animated-bgs/4K/29_4k.mp4 b/apps/web/public/animated-bgs/4K/29_4k.mp4 new file mode 100644 index 0000000000..c832ee8dfb Binary files /dev/null and b/apps/web/public/animated-bgs/4K/29_4k.mp4 differ diff --git a/apps/web/public/animated-bgs/4K/2_4k.mp4 b/apps/web/public/animated-bgs/4K/2_4k.mp4 new file mode 100644 index 0000000000..eba88ab3c1 Binary files /dev/null and b/apps/web/public/animated-bgs/4K/2_4k.mp4 differ diff --git a/apps/web/public/animated-bgs/4K/30_4k.mp4 b/apps/web/public/animated-bgs/4K/30_4k.mp4 new file mode 100644 index 0000000000..2d12bf66f5 Binary files /dev/null and b/apps/web/public/animated-bgs/4K/30_4k.mp4 differ diff --git a/apps/web/public/animated-bgs/4K/3_4k.mp4 b/apps/web/public/animated-bgs/4K/3_4k.mp4 new file mode 100644 index 0000000000..f6648bc053 Binary files /dev/null and b/apps/web/public/animated-bgs/4K/3_4k.mp4 differ diff --git a/apps/web/public/animated-bgs/4K/4_4k.mp4 b/apps/web/public/animated-bgs/4K/4_4k.mp4 new file mode 100644 index 0000000000..9a6d17e48f Binary files /dev/null and b/apps/web/public/animated-bgs/4K/4_4k.mp4 differ diff --git a/apps/web/public/animated-bgs/4K/5_4k.mp4 b/apps/web/public/animated-bgs/4K/5_4k.mp4 new file mode 100644 index 0000000000..a2113bd503 Binary files /dev/null and b/apps/web/public/animated-bgs/4K/5_4k.mp4 differ diff --git a/apps/web/public/animated-bgs/4K/6_4k.mp4 b/apps/web/public/animated-bgs/4K/6_4k.mp4 new file mode 100644 index 0000000000..d7c3c1274e Binary files /dev/null and b/apps/web/public/animated-bgs/4K/6_4k.mp4 differ diff --git a/apps/web/public/animated-bgs/4K/7_4k.mp4 b/apps/web/public/animated-bgs/4K/7_4k.mp4 new file mode 100644 index 0000000000..31b9031926 Binary files /dev/null and b/apps/web/public/animated-bgs/4K/7_4k.mp4 differ diff --git a/apps/web/public/animated-bgs/4K/8_4k.mp4 b/apps/web/public/animated-bgs/4K/8_4k.mp4 new file mode 100644 index 0000000000..c55a35ab4e Binary files /dev/null and b/apps/web/public/animated-bgs/4K/8_4k.mp4 differ diff --git a/apps/web/public/animated-bgs/4K/9_4k.mp4 b/apps/web/public/animated-bgs/4K/9_4k.mp4 new file mode 100644 index 0000000000..5806295f37 Binary files /dev/null and b/apps/web/public/animated-bgs/4K/9_4k.mp4 differ diff --git a/apps/web/public/animated-bgs/Thumbnails/10_Thumb.mp4 b/apps/web/public/animated-bgs/Thumbnails/10_Thumb.mp4 new file mode 100644 index 0000000000..c8a16ce1f2 Binary files /dev/null and b/apps/web/public/animated-bgs/Thumbnails/10_Thumb.mp4 differ diff --git a/apps/web/public/animated-bgs/Thumbnails/11_Thumb.mp4 b/apps/web/public/animated-bgs/Thumbnails/11_Thumb.mp4 new file mode 100644 index 0000000000..dfa299f6ce Binary files /dev/null and b/apps/web/public/animated-bgs/Thumbnails/11_Thumb.mp4 differ diff --git a/apps/web/public/animated-bgs/Thumbnails/12_Thumb.mp4 b/apps/web/public/animated-bgs/Thumbnails/12_Thumb.mp4 new file mode 100644 index 0000000000..014973c61d Binary files /dev/null and b/apps/web/public/animated-bgs/Thumbnails/12_Thumb.mp4 differ diff --git a/apps/web/public/animated-bgs/Thumbnails/13_Thumb.mp4 b/apps/web/public/animated-bgs/Thumbnails/13_Thumb.mp4 new file mode 100644 index 0000000000..ca5f312456 Binary files /dev/null and b/apps/web/public/animated-bgs/Thumbnails/13_Thumb.mp4 differ diff --git a/apps/web/public/animated-bgs/Thumbnails/14_Thumb.mp4 b/apps/web/public/animated-bgs/Thumbnails/14_Thumb.mp4 new file mode 100644 index 0000000000..4e99ea1e41 Binary files /dev/null and b/apps/web/public/animated-bgs/Thumbnails/14_Thumb.mp4 differ diff --git a/apps/web/public/animated-bgs/Thumbnails/15_Thumb.mp4 b/apps/web/public/animated-bgs/Thumbnails/15_Thumb.mp4 new file mode 100644 index 0000000000..9b584ae7de Binary files /dev/null and b/apps/web/public/animated-bgs/Thumbnails/15_Thumb.mp4 differ diff --git a/apps/web/public/animated-bgs/Thumbnails/16_Thumb.mp4 b/apps/web/public/animated-bgs/Thumbnails/16_Thumb.mp4 new file mode 100644 index 0000000000..d268a61978 Binary files /dev/null and b/apps/web/public/animated-bgs/Thumbnails/16_Thumb.mp4 differ diff --git a/apps/web/public/animated-bgs/Thumbnails/17_Thumb.mp4 b/apps/web/public/animated-bgs/Thumbnails/17_Thumb.mp4 new file mode 100755 index 0000000000..f94ced7c31 Binary files /dev/null and b/apps/web/public/animated-bgs/Thumbnails/17_Thumb.mp4 differ diff --git a/apps/web/public/animated-bgs/Thumbnails/18_Thumb.mp4 b/apps/web/public/animated-bgs/Thumbnails/18_Thumb.mp4 new file mode 100755 index 0000000000..9c7ebd9289 Binary files /dev/null and b/apps/web/public/animated-bgs/Thumbnails/18_Thumb.mp4 differ diff --git a/apps/web/public/animated-bgs/Thumbnails/19_Thumb.mp4 b/apps/web/public/animated-bgs/Thumbnails/19_Thumb.mp4 new file mode 100755 index 0000000000..4f7e3f67f4 Binary files /dev/null and b/apps/web/public/animated-bgs/Thumbnails/19_Thumb.mp4 differ diff --git a/apps/web/public/animated-bgs/Thumbnails/1_Thumb.mp4 b/apps/web/public/animated-bgs/Thumbnails/1_Thumb.mp4 new file mode 100644 index 0000000000..84c21828d2 Binary files /dev/null and b/apps/web/public/animated-bgs/Thumbnails/1_Thumb.mp4 differ diff --git a/apps/web/public/animated-bgs/Thumbnails/20_Thumb.mp4 b/apps/web/public/animated-bgs/Thumbnails/20_Thumb.mp4 new file mode 100644 index 0000000000..b0d1044b62 Binary files /dev/null and b/apps/web/public/animated-bgs/Thumbnails/20_Thumb.mp4 differ diff --git a/apps/web/public/animated-bgs/Thumbnails/21_Thumb.mp4 b/apps/web/public/animated-bgs/Thumbnails/21_Thumb.mp4 new file mode 100755 index 0000000000..b71a30240a Binary files /dev/null and b/apps/web/public/animated-bgs/Thumbnails/21_Thumb.mp4 differ diff --git a/apps/web/public/animated-bgs/Thumbnails/22_Thumb.mp4 b/apps/web/public/animated-bgs/Thumbnails/22_Thumb.mp4 new file mode 100644 index 0000000000..bcad8e0992 Binary files /dev/null and b/apps/web/public/animated-bgs/Thumbnails/22_Thumb.mp4 differ diff --git a/apps/web/public/animated-bgs/Thumbnails/23_Thumb.mp4 b/apps/web/public/animated-bgs/Thumbnails/23_Thumb.mp4 new file mode 100644 index 0000000000..1a11ab3573 Binary files /dev/null and b/apps/web/public/animated-bgs/Thumbnails/23_Thumb.mp4 differ diff --git a/apps/web/public/animated-bgs/Thumbnails/24_Thumb.mp4 b/apps/web/public/animated-bgs/Thumbnails/24_Thumb.mp4 new file mode 100644 index 0000000000..c349988721 Binary files /dev/null and b/apps/web/public/animated-bgs/Thumbnails/24_Thumb.mp4 differ diff --git a/apps/web/public/animated-bgs/Thumbnails/25_Thumb.mp4 b/apps/web/public/animated-bgs/Thumbnails/25_Thumb.mp4 new file mode 100644 index 0000000000..2fe977fbbe Binary files /dev/null and b/apps/web/public/animated-bgs/Thumbnails/25_Thumb.mp4 differ diff --git a/apps/web/public/animated-bgs/Thumbnails/26_Thumb.mp4 b/apps/web/public/animated-bgs/Thumbnails/26_Thumb.mp4 new file mode 100644 index 0000000000..3bc9e721e4 Binary files /dev/null and b/apps/web/public/animated-bgs/Thumbnails/26_Thumb.mp4 differ diff --git a/apps/web/public/animated-bgs/Thumbnails/27_Thumb.mp4 b/apps/web/public/animated-bgs/Thumbnails/27_Thumb.mp4 new file mode 100644 index 0000000000..7f87d44557 Binary files /dev/null and b/apps/web/public/animated-bgs/Thumbnails/27_Thumb.mp4 differ diff --git a/apps/web/public/animated-bgs/Thumbnails/28_Thumb.mp4 b/apps/web/public/animated-bgs/Thumbnails/28_Thumb.mp4 new file mode 100644 index 0000000000..ad842b7bf2 Binary files /dev/null and b/apps/web/public/animated-bgs/Thumbnails/28_Thumb.mp4 differ diff --git a/apps/web/public/animated-bgs/Thumbnails/29_Thumb.mp4 b/apps/web/public/animated-bgs/Thumbnails/29_Thumb.mp4 new file mode 100644 index 0000000000..f487d82178 Binary files /dev/null and b/apps/web/public/animated-bgs/Thumbnails/29_Thumb.mp4 differ diff --git a/apps/web/public/animated-bgs/Thumbnails/2_Thumb.mp4 b/apps/web/public/animated-bgs/Thumbnails/2_Thumb.mp4 new file mode 100644 index 0000000000..53424236f8 Binary files /dev/null and b/apps/web/public/animated-bgs/Thumbnails/2_Thumb.mp4 differ diff --git a/apps/web/public/animated-bgs/Thumbnails/30_Thumb.mp4 b/apps/web/public/animated-bgs/Thumbnails/30_Thumb.mp4 new file mode 100644 index 0000000000..78cd76f39c Binary files /dev/null and b/apps/web/public/animated-bgs/Thumbnails/30_Thumb.mp4 differ diff --git a/apps/web/public/animated-bgs/Thumbnails/3_Thumb.mp4 b/apps/web/public/animated-bgs/Thumbnails/3_Thumb.mp4 new file mode 100644 index 0000000000..2ebfb5175d Binary files /dev/null and b/apps/web/public/animated-bgs/Thumbnails/3_Thumb.mp4 differ diff --git a/apps/web/public/animated-bgs/Thumbnails/4_Thumb.mp4 b/apps/web/public/animated-bgs/Thumbnails/4_Thumb.mp4 new file mode 100644 index 0000000000..b029a786b7 Binary files /dev/null and b/apps/web/public/animated-bgs/Thumbnails/4_Thumb.mp4 differ diff --git a/apps/web/public/animated-bgs/Thumbnails/5_Thumb.mp4 b/apps/web/public/animated-bgs/Thumbnails/5_Thumb.mp4 new file mode 100644 index 0000000000..e5bb6a9a75 Binary files /dev/null and b/apps/web/public/animated-bgs/Thumbnails/5_Thumb.mp4 differ diff --git a/apps/web/public/animated-bgs/Thumbnails/6_Thumb.mp4 b/apps/web/public/animated-bgs/Thumbnails/6_Thumb.mp4 new file mode 100644 index 0000000000..fdbce89967 Binary files /dev/null and b/apps/web/public/animated-bgs/Thumbnails/6_Thumb.mp4 differ diff --git a/apps/web/public/animated-bgs/Thumbnails/7_Thumb.mp4 b/apps/web/public/animated-bgs/Thumbnails/7_Thumb.mp4 new file mode 100644 index 0000000000..59aebeb6b8 Binary files /dev/null and b/apps/web/public/animated-bgs/Thumbnails/7_Thumb.mp4 differ diff --git a/apps/web/public/animated-bgs/Thumbnails/8_Thumb.mp4 b/apps/web/public/animated-bgs/Thumbnails/8_Thumb.mp4 new file mode 100644 index 0000000000..323028dc4a Binary files /dev/null and b/apps/web/public/animated-bgs/Thumbnails/8_Thumb.mp4 differ diff --git a/apps/web/public/animated-bgs/Thumbnails/9_Thumb.mp4 b/apps/web/public/animated-bgs/Thumbnails/9_Thumb.mp4 new file mode 100644 index 0000000000..5a1e55c1f5 Binary files /dev/null and b/apps/web/public/animated-bgs/Thumbnails/9_Thumb.mp4 differ diff --git a/apps/web/sentry.client.config.ts b/apps/web/sentry.client.config.ts index c006b55429..bf1b468a5e 100644 --- a/apps/web/sentry.client.config.ts +++ b/apps/web/sentry.client.config.ts @@ -1,7 +1,6 @@ // This file configures the initialization of Sentry on the client. // The config you add here will be used whenever a users loads a page in their browser. // https://docs.sentry.io/platforms/javascript/guides/nextjs/ - import * as Sentry from "@sentry/nextjs"; Sentry.init({ @@ -27,4 +26,14 @@ Sentry.init({ blockAllMedia: true, }), ], + beforeSend(event, hint) { + const error = hint.originalException as Error; + + // @ts-expect-error + if (error && error.digest === "NEXT_NOT_FOUND") { + return null; + } + + return event; + }, }); diff --git a/apps/web/sentry.edge.config.ts b/apps/web/sentry.edge.config.ts index efc59b6adb..45a2a3a210 100644 --- a/apps/web/sentry.edge.config.ts +++ b/apps/web/sentry.edge.config.ts @@ -2,7 +2,6 @@ // The config you add here will be used whenever one of the edge features is loaded. // Note that this config is unrelated to the Vercel Edge Runtime and is also required when running locally. // https://docs.sentry.io/platforms/javascript/guides/nextjs/ - import * as Sentry from "@sentry/nextjs"; Sentry.init({ diff --git a/apps/web/sentry.server.config.ts b/apps/web/sentry.server.config.ts index 86d85e1687..6b8a968318 100644 --- a/apps/web/sentry.server.config.ts +++ b/apps/web/sentry.server.config.ts @@ -1,7 +1,6 @@ // This file configures the initialization of Sentry on the server. // The config you add here will be used whenever the server handles a request. // https://docs.sentry.io/platforms/javascript/guides/nextjs/ - import * as Sentry from "@sentry/nextjs"; Sentry.init({ @@ -12,4 +11,14 @@ Sentry.init({ // Setting this option to true will print useful information to the console while you're setting up Sentry. debug: false, + beforeSend(event, hint) { + const error = hint.originalException as Error; + + // @ts-expect-error + if (error && error.digest === "NEXT_NOT_FOUND") { + return null; + } + + return event; + }, }); diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 5a280c7f14..235fb33d92 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -1,13 +1,6 @@ { "extends": "@formbricks/tsconfig/nextjs.json", - "include": [ - "next-env.d.ts", - "**/*.ts", - "**/*.tsx", - ".next/types/**/*.ts", - "../../packages/types/*.d.ts", - "../../packages/lib/jwt.ts" - ], + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "../../packages/types/*.d.ts"], "exclude": ["../../.env"], "compilerOptions": { "baseUrl": ".", diff --git a/apps/web/vercel.json b/apps/web/vercel.json index 339b7d81e4..16fa2ac92d 100644 --- a/apps/web/vercel.json +++ b/apps/web/vercel.json @@ -1,5 +1,8 @@ { "functions": { + "app/api/cron/weekly_summary/*.ts": { + "maxDuration": 30 + }, "app/api/v1/js/sync/*.ts": { "maxDuration": 5 }, diff --git a/docker-compose.yml b/docker-compose.yml index b15febe476..9d222cbdfb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,8 +1,5 @@ version: "3.3" -# If you already have a local .env then please run this using -# docker compose --env-file /dev/null up - # This should be the same as below if you are running via docker compose up x-webapp-url: &webapp_url http://localhost:3000 @@ -11,34 +8,30 @@ x-database-url: &database_url postgresql://postgres:postgres@postgres:5432/formb # NextJS Auth # @see: https://next-auth.js.org/configuration/options#nextauth_secret -# You can use: `openssl rand -base64 32` to generate one -x-nextauth-secret: &nextauth_secret luJthrnoDpVgGakjVYlccsZ1FdlwxIWogWIsrxzoQ6E= +# You can use: `openssl rand -hex 32` to generate one +x-nextauth-secret: &nextauth_secret 10ee8bc17d40a457544cf373affbab16 # Set this to your public-facing URL, e.g., https://example.com # You do not need the NEXTAUTH_URL environment variable in Vercel. x-nextauth-url: &nextauth_url http://localhost:3000 # Encryption key -# You can use: `openssl rand -base64 16` to generate one -x-formbricks-encryption-key: &formbricks_encryption_key +# You can use: `openssl rand -hex 32` to generate one +x-encryption-key: &encryption_key 1b3d888592454d23b520040950654669 -# Necessary if email verification and password reset are enabled. -# See further below if you want to disable these features. x-mail-from: &mail_from x-smtp-host: &smtp_host x-smtp-port: &smtp_port -# Enable SMTP_SECURE_ENABLED for TLS (port 465) -x-smtp-secure-enabled: &smtp_secure_enabled +x-smtp-secure-enabled: &smtp_secure_enabled # Enable SMTP_SECURE_ENABLED for TLS (port 465) + + x-smtp-user: &smtp_user x-smtp-password: &smtp_password -# Set the below value to your public-facing URL, e.g., https://example.com -x-survey-base-url: &survey_base_url http://localhost:3000/s +x-short-url-base: + &short_url_base # Set the below value if you have and want to share a shorter base URL than the x-survey-base-url -# Set the below value if you have and want to share a shorter base URL than the x-survey-base-url -x-short-survey-base-url: &short_survey_base_url -# Email Verification. If you enable Email Verification you have to setup SMTP-Settings, too. x-email-verification-disabled: &email_verification_disabled 1 # Password Reset. If you enable Password Reset functionality you have to setup SMTP-Settings, too. @@ -55,28 +48,21 @@ x-privacy-url: &privacy_url x-terms-url: &terms_url x-imprint-url: &imprint_url - -# Configure Github Login -x-github-auth-enabled: &github_auth_enabled 0 +x-github-auth-enabled: &github_auth_enabled 0 # Configure Github Login x-github-id: &github_id x-github-secret: &github_secret -# Configure Google Login -x-google-auth-enabled: &google_auth_enabled 0 +x-google-auth-enabled: &google_auth_enabled 0 # Configure Google Login x-google-client-id: &google_client_id x-google-client-secret: &google_client_secret -# Disable Sentry warning -x-sentry-ignore-api-resolution-error: &sentry_ignore_api_resolution_error +x-sentry-ignore-api-resolution-error: &sentry_ignore_api_resolution_error # Disable Sentry warning -# Enable Sentry Error Tracking -x-next-public-sentry-dsn: &next_public_sentry_dsn -# Cron Secret -x-cron-secret: &cron_secret +x-next-public-sentry-dsn: &next_public_sentry_dsn # Enable Sentry Error Tracking -# Configure ASSET_PREFIX_URL when you want to ship JS & CSS files from a complete URL instead of the current domain -x-asset-prefix-url: &asset_prefix_url + +x-cron-secret: &cron_secret YOUR_CRON_SECRET # Set this to a random string to secure your cron endpoints services: postgres: @@ -95,7 +81,8 @@ services: args: DATABASE_URL: *database_url NEXTAUTH_SECRET: *nextauth_secret - + ENCRYPTION_KEY: *encryption_key + depends_on: - postgres ports: @@ -112,9 +99,8 @@ services: SMTP_SECURE_ENABLED: *smtp_secure_enabled SMTP_USER: *smtp_user SMTP_PASSWORD: *smtp_password - FORMBRICKS_ENCRYPTION_KEY: *formbricks_encryption_key - SURVEY_BASE_URL: *survey_base_url - SHORT_SURVEY_BASE_URL: *short_survey_base_url + ENCRYPTION_KEY: *encryption_key + SHORT_URL_BASE: *short_url_base PRIVACY_URL: *privacy_url TERMS_URL: *terms_url IMPRINT_URL: *imprint_url @@ -131,8 +117,11 @@ services: GOOGLE_CLIENT_ID: *google_client_id GOOGLE_CLIENT_SECRET: *google_client_secret CRON_SECRET: *cron_secret - ASSET_PREFIX_URL: *asset_prefix_url + + volumes: + - uploads:/home/nextjs/apps/web/uploads/ volumes: postgres: driver: local + uploads: diff --git a/docker/cronjobs b/docker/cronjobs new file mode 100644 index 0000000000..bb932e480e --- /dev/null +++ b/docker/cronjobs @@ -0,0 +1,3 @@ +0 0 * * * curl $WEBAPP_URL/api/cron/close_surveys -X POST -H 'content-type: application/json' -H 'x-api-key: '"$CRON_SECRET"'' +0 8 * * 1 curl $WEBAPP_URL/api/cron/weekly_summary -X POST -H 'content-type: application/json' -H 'x-api-key: '"$CRON_SECRET"'' +0 9 * * * curl $WEBAPP_URL/api/cron/ping -X POST -H 'content-type: application/json' -H 'x-api-key: '"$CRON_SECRET"'' diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 08a0444fac..405943ed5f 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -9,39 +9,40 @@ x-environment: &environment # NextJS Auth # @see: https://next-auth.js.org/configuration/options#nextauth_secret - # You can use: `openssl rand -base64 32` to generate one + # You can use: `openssl rand -hex 32` to generate one NEXTAUTH_SECRET: # Set this to your public-facing URL, e.g., https://example.com # You do not need the NEXTAUTH_URL environment variable in Vercel. NEXTAUTH_URL: http://localhost:3000 - # Formbricks Encryption Key is used to generate encrypted single use URLs for Link Surveys - # You can use: $(openssl rand -base64 16) to generate one - # FORMBRICKS_ENCRYPTION_KEY: + # Encryption Key is used for 2FA & Single use URLs for Link Surveys + # You can use: $(openssl rand -hex 32) to generate one + ENCRYPTION_KEY: # PostgreSQL password POSTGRES_PASSWORD: postgres - # Email Configuration - MAIL_FROM: - SMTP_HOST: - SMTP_PORT: - SMTP_SECURE_ENABLED: - SMTP_USER: - SMTP_PASSWORD: + # Enterprise License Key + # Required to access Enterprise-only features + # ENTERPRISE_LICENSE_KEY: - # Set the below value if you want to have another base URL apart from your Domain Name - # SURVEY_BASE_URL: + # Email Configuration + # MAIL_FROM: + # SMTP_HOST: + # SMTP_PORT: + # SMTP_SECURE_ENABLED: + # SMTP_USER: + # SMTP_PASSWORD: # Set the below value if you have and want to use a custom URL for the links created by the Link Shortener - # SHORT_SURVEY_BASE_URL: + # SHORT_URL_BASE: - # Uncomment the below and set it to 1 to disable Email Verification for new signups - # EMAIL_VERIFICATION_DISABLED: + # Set the below to 0 to enable Email Verification for new signups (will required Email Configuration) + EMAIL_VERIFICATION_DISABLED: 1 - # Uncomment the below and set it to 1 to disable Password Reset - # PASSWORD_RESET_DISABLED: + # Set the below to 0 to enable Password Reset (will required Email Configuration) + PASSWORD_RESET_DISABLED: 1 # Uncomment the below and set it to 1 to disable Signups # SIGNUP_DISABLED: @@ -68,8 +69,14 @@ x-environment: &environment # GOOGLE_CLIENT_ID: # GOOGLE_CLIENT_SECRET: - # Configure ASSET_PREFIX_URL when you want to ship JS & CSS files from a complete URL instead of the current domain - # ASSET_PREFIX_URL: *asset_prefix_url + # Uncomment the below to automatically assign new users to a specific team and role within that team + # Insert an existing team id or generate a valid CUID for a new one at https://www.getuniqueid.com/cuid (e.g. cjld2cjxh0000qzrmn831i7rn) + # (Role Management is an Enterprise feature) + # DEFAULT_TEAM_ID: + # DEFAULT_TEAM_ROLE: admin + + # Uncomment and set to 1 to skip onboarding for new users + # ONBOARDING_DISABLED: 1 services: postgres: @@ -81,13 +88,13 @@ services: formbricks: restart: always - image: formbricks/formbricks:latest + image: ghcr.io/formbricks/formbricks:latest depends_on: - postgres ports: - 3000:3000 volumes: - - uploads:/apps/web/uploads/ + - uploads:/home/nextjs/apps/web/uploads/ <<: *environment volumes: diff --git a/docker/production.sh b/docker/production.sh old mode 100644 new mode 100755 index 4a4126b3b9..1baa58ba52 --- a/docker/production.sh +++ b/docker/production.sh @@ -3,70 +3,72 @@ set -e ubuntu_version=$(lsb_release -a 2>/dev/null | grep -v "No LSB modules are available." | grep "Description:" | awk -F "Description:\t" '{print $2}') -# Friendly welcome -echo "🧱 Welcome to the Formbricks single instance installer" -echo "" -echo "🛸 Fasten your seatbelts! We're setting up your Formbricks environment on your $ubuntu_version server." -echo "" +install_formbricks() { + # Friendly welcome + echo "🧱 Welcome to the Formbricks Setup Script" + echo "" + echo "🛸 Fasten your seatbelts! We're setting up your Formbricks environment on your $ubuntu_version server." + echo "" -# Remove any old Docker installations, without stopping the script if they're not found -echo "🧹 Time to sweep away any old Docker installations." -sudo apt-get remove docker docker-engine docker.io containerd runc >/dev/null 2>&1 || true + # Remove any old Docker installations, without stopping the script if they're not found + echo "🧹 Time to sweep away any old Docker installations." + sudo apt-get remove docker docker-engine docker.io containerd runc >/dev/null 2>&1 || true -# Update package list -echo "🔄 Updating your package list." -sudo apt-get update >/dev/null 2>&1 + # Update package list + echo "🔄 Updating your package list." + sudo apt-get update >/dev/null 2>&1 -# Install dependencies -echo "📦 Installing the necessary dependencies." -sudo apt-get install -y \ - ca-certificates \ - curl \ - gnupg \ - lsb-release >/dev/null 2>&1 + # Install dependencies + echo "📦 Installing the necessary dependencies." + sudo apt-get install -y \ + ca-certificates \ + curl \ + gnupg \ + lsb-release >/dev/null 2>&1 -# Set up Docker's official GPG key & stable repository -echo "🔑 Adding Docker's official GPG key and setting up the stable repository." -sudo mkdir -m 0755 -p /etc/apt/keyrings >/dev/null 2>&1 -curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg >/dev/null 2>&1 -echo \ - "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \ + # Set up Docker's official GPG key & stable repository + echo "🔑 Adding Docker's official GPG key and setting up the stable repository." + sudo mkdir -m 0755 -p /etc/apt/keyrings >/dev/null 2>&1 + curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg >/dev/null 2>&1 + echo \ + "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \ $(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list >/dev/null 2>&1 -# Update package list again -echo "🔄 Updating your package list again." -sudo apt-get update >/dev/null 2>&1 + # Update package list again + echo "🔄 Updating your package list again." + sudo apt-get update >/dev/null 2>&1 -# Install Docker -echo "🐳 Installing Docker." -sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin >/dev/null 2>&1 + # Install Docker + echo "🐳 Installing Docker." + sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin >/dev/null 2>&1 -# Test Docker installation -echo "🚀 Testing your Docker installation." -if docker --version >/dev/null 2>&1; then - echo "🎉 Docker is installed!" -else - echo "❌ Docker is not installed. Please install Docker before proceeding." - exit 1 -fi + # Test Docker installation + echo "🚀 Testing your Docker installation." + if docker --version >/dev/null 2>&1; then + echo "🎉 Docker is installed!" + else + echo "❌ Docker is not installed. Please install Docker before proceeding." + exit 1 + fi -# Adding your user to the Docker group -echo "🐳 Adding your user to the Docker group to avoid using sudo with docker commands." -sudo groupadd docker >/dev/null 2>&1 || true -sudo usermod -aG docker $USER >/dev/null 2>&1 + # Adding your user to the Docker group + echo "🐳 Adding your user to the Docker group to avoid using sudo with docker commands." + sudo groupadd docker >/dev/null 2>&1 || true + sudo usermod -aG docker $USER >/dev/null 2>&1 -echo "🎉 Hooray! Docker is all set and ready to go. You're now ready to run your Formbricks instance!" + echo "🎉 Hooray! Docker is all set and ready to go. You're now ready to run your Formbricks instance!" -# Installing Traefik -echo "🚗 Installing Traefik..." -mkdir -p formbricks && cd formbricks -echo "📁 Created Formbricks Quickstart directory at ./formbricks." + mkdir -p formbricks && cd formbricks + echo "📁 Created Formbricks Quickstart directory at ./formbricks." -# Ask the user for their email address -echo "💡 Please enter your email address for the SSL certificate:" -read email_address + # Ask the user for their email address + echo "💡 Please enter your email address for the SSL certificate:" + read email_address -cat <traefik.yaml + # Installing Traefik + echo "🚗 Configuring Traefik..." + + cat <traefik.yaml entryPoints: web: address: ":80" @@ -94,185 +96,109 @@ certificatesResolvers: tlsChallenge: {} EOT -echo "💡 Created traefik.yaml file with your provided email address." + echo "💡 Created traefik.yaml file with your provided email address." -touch acme.json -chmod 600 acme.json -echo "💡 Created acme.json file with correct permissions." + touch acme.json + chmod 600 acme.json + echo "💡 Created acme.json file with correct permissions." -# Ask the user for their email address -echo "🔗 Please enter your domain name for the SSL certificate (🚨 do NOT enter the protocol (http/https/etc)):" -read domain_name + # Ask the user for their domain name + echo "🔗 Please enter your domain name for the SSL certificate (🚨 do NOT enter the protocol (http/https/etc)):" + read domain_name -# Prompt for email service setup -read -p "Do you want to set up the email service? (yes/no) You will need SMTP credentials for the same! " email_service -if [[ $email_service == "yes" ]]; then - echo "Please provide the following email service details: " + # Prompt for email service setup + read -p "Do you want to set up the email service? (yes/no) You will need SMTP credentials for the same! " email_service + if [[ $email_service == "yes" ]]; then + echo "Please provide the following email service details: " - echo -n "Enter your SMTP configured Email ID: " - read mail_from + echo -n "Enter your SMTP configured Email ID: " + read mail_from - echo -n "Enter your SMTP Host URL: " - read smtp_host + echo -n "Enter your SMTP Host URL: " + read smtp_host - echo -n "Enter your SMTP Host Port: " - read smtp_port + echo -n "Enter your SMTP Host Port: " + read smtp_port - echo -n "Enter your SMTP username: " - read smtp_user + echo -n "Enter your SMTP username: " + read smtp_user - echo -n "Enter your SMTP password: " - read smtp_password + echo -n "Enter your SMTP password: " + read smtp_password - echo -n "Enable Secure SMTP (use SSL)? Enter 1 for yes and 0 for no: " - read smtp_secure_enabled + echo -n "Enable Secure SMTP (use SSL)? Enter 1 for yes and 0 for no: " + read smtp_secure_enabled -else - mail_from="" - smtp_host="" - smtp_port="" - smtp_user="" - smtp_password="" - smtp_secure_enabled=0 -fi + else + mail_from="" + smtp_host="" + smtp_port="" + smtp_user="" + smtp_password="" + smtp_secure_enabled=0 + fi -if [[ -n $mail_from ]]; then - email_config=$( - cat <docker-compose.yml -version: "3.3" -x-environment: &environment - environment: - # The url of your Formbricks instance used in the admin panel - WEBAPP_URL: "https://$domain_name" + echo "🚙 Updating docker-compose.yml with your custom inputs..." + sed -i "/WEBAPP_URL:/s|WEBAPP_URL:.*|WEBAPP_URL: \"https://$domain_name\"|" docker-compose.yml + sed -i "/NEXTAUTH_URL:/s|NEXTAUTH_URL:.*|NEXTAUTH_URL: \"https://$domain_name\"|" docker-compose.yml - # PostgreSQL DB for Formbricks to connect to - DATABASE_URL: "postgresql://postgres:postgres@postgres:5432/formbricks?schema=public" + nextauth_secret=$(openssl rand -hex 32) && sed -i "/NEXTAUTH_SECRET:$/s/NEXTAUTH_SECRET:.*/NEXTAUTH_SECRET: $nextauth_secret/" docker-compose.yml + echo "🚗 NEXTAUTH_SECRET updated successfully!" - # NextJS Auth - # @see: https://next-auth.js.org/configuration/options#nextauth_secret - # You can use: $(openssl rand -base64 32) to generate one - NEXTAUTH_SECRET: + encryption_key=$(openssl rand -hex 32) && sed -i "/ENCRYPTION_KEY:$/s/ENCRYPTION_KEY:.*/ENCRYPTION_KEY: $encryption_key/" docker-compose.yml + echo "🚗 ENCRYPTION_KEY updated successfully!" - # 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: "https://$domain_name" + if [[ -n $mail_from ]]; then + sed -i "s|# MAIL_FROM:|MAIL_FROM: \"$mail_from\"|" docker-compose.yml + sed -i "s|# SMTP_HOST:|SMTP_HOST: \"$smtp_host\"|" docker-compose.yml + sed -i "s|# SMTP_PORT:|SMTP_PORT: \"$smtp_port\"|" docker-compose.yml + sed -i "s|# SMTP_SECURE_ENABLED:|SMTP_SECURE_ENABLED: $smtp_secure_enabled|" docker-compose.yml + sed -i "s|# SMTP_USER:|SMTP_USER: \"$smtp_user\"|" docker-compose.yml + sed -i "s|# SMTP_PASSWORD:|SMTP_PASSWORD: \"$smtp_password\"|" docker-compose.yml + fi - # Formbricks Encryption Key is used to generate encrypted single use URLs for Link Surveys - # You can use: $(openssl rand -base64 16) to generate one - FORMBRICKS_ENCRYPTION_KEY: + awk -v domain_name="$domain_name" ' +/formbricks:/,/^ *$/ { + if ($0 ~ /depends_on:/) { + inserting_labels=1 + } + if (inserting_labels && ($0 ~ /ports:/)) { + print " labels:" + print " - \"traefik.enable=true\" # Enable Traefik for this service" + print " - \"traefik.http.routers.formbricks.rule=Host(\`" domain_name "\`)\" # Use your actual domain or IP" + print " - \"traefik.http.routers.formbricks.entrypoints=websecure\" # Use the websecure entrypoint (port 443 with TLS)" + print " - \"traefik.http.services.formbricks.loadbalancer.server.port=3000\" # Forward traffic to Formbricks on port 3000" + inserting_labels=0 + } + print + next +} +/^volumes:/ { + print " traefik:" + print " image: \"traefik:v2.7\"" + print " restart: always" + print " container_name: \"traefik\"" + print " depends_on:" + print " - formbricks" + print " ports:" + print " - \"80:80\"" + print " - \"443:443\"" + print " - \"8080:8080\"" + print " volumes:" + print " - ./traefik.yaml:/traefik.yaml" + print " - ./acme.json:/acme.json" + print " - /var/run/docker.sock:/var/run/docker.sock:ro" + print "" +} +1 +' docker-compose.yml >tmp.yml && mv tmp.yml docker-compose.yml - # PostgreSQL password - POSTGRES_PASSWORD: postgres + newgrp docker <=16.0.0" }, - "packageManager": "pnpm@8.1.1", + "packageManager": "pnpm@8.11.0", "nextBundleAnalysis": { "budget": 358400, "budgetPercentIncreaseRed": 20, "minimumChangeThreshold": 0, "showDetails": true + }, + "dependencies": { + "@changesets/cli": "^2.27.1", + "playwright": "^1.40.1" } } diff --git a/packages/api/.eslintrc.cjs b/packages/api/.eslintrc.cjs index bcf4aad3a3..d6151a6966 100644 --- a/packages/api/.eslintrc.cjs +++ b/packages/api/.eslintrc.cjs @@ -1,4 +1,3 @@ module.exports = { - root: true, - extends: ["formbricks"], -}; + extends: [ "turbo", "prettier"], +}; \ No newline at end of file diff --git a/packages/api/README.md b/packages/api/README.md index b6e2fbc266..84c961ab2f 100644 --- a/packages/api/README.md +++ b/packages/api/README.md @@ -12,7 +12,7 @@ npm install @formbricks/api Create API Client ```ts -import { FormbricksAPI, EnvironmentId } from "@formbricks/api"; +import { EnvironmentId, FormbricksAPI } from "@formbricks/api"; const api = new FormbricksAPI({ apiHost: "http://localhost:3000", diff --git a/packages/api/package.json b/packages/api/package.json index e7ed5ed80d..b63f2d12d9 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -1,7 +1,7 @@ { "name": "@formbricks/api", "license": "MIT", - "version": "1.0.0", + "version": "1.4.0", "description": "Formbricks-api is an api wrapper for the Formbricks client API", "keywords": [ "Formbricks", @@ -32,11 +32,11 @@ }, "devDependencies": { "@formbricks/types": "workspace:*", - "@formbricks/lib": "workspace:*", "@formbricks/tsconfig": "workspace:*", - "eslint-config-formbricks": "workspace:*", - "terser": "^5.22.0", - "vite": "^4.4.11", - "vite-plugin-dts": "^3.6.0" + "eslint-config-prettier": "^9.1.0", + "eslint-config-turbo": "latest", + "terser": "^5.26.0", + "vite": "^5.0.10", + "vite-plugin-dts": "^3.7.0" } } diff --git a/packages/api/src/api/client/action.ts b/packages/api/src/api/client/action.ts new file mode 100644 index 0000000000..b72e59802c --- /dev/null +++ b/packages/api/src/api/client/action.ts @@ -0,0 +1,19 @@ +import { TActionInput } from "@formbricks/types/actions"; +import { Result } from "@formbricks/types/errorHandlers"; +import { NetworkError } from "@formbricks/types/errors"; + +import { makeRequest } from "../../utils/makeRequest"; + +export class ActionAPI { + private apiHost: string; + private environmentId: string; + + constructor(apiHost: string, environmentId: string) { + this.apiHost = apiHost; + this.environmentId = environmentId; + } + + async create(actionInput: Omit): Promise> { + return makeRequest(this.apiHost, `/api/v1/client/${this.environmentId}/actions`, "POST", actionInput); + } +} diff --git a/packages/api/src/api/client/display.ts b/packages/api/src/api/client/display.ts index 400cfd4a64..514f970401 100644 --- a/packages/api/src/api/client/display.ts +++ b/packages/api/src/api/client/display.ts @@ -1,23 +1,33 @@ -import { Result } from "@formbricks/types/v1/errorHandlers"; -import { NetworkError } from "@formbricks/types/v1/errors"; +import { TDisplayCreateInput, TDisplayUpdateInput } from "@formbricks/types/displays"; +import { Result } from "@formbricks/types/errorHandlers"; +import { NetworkError } from "@formbricks/types/errors"; + import { makeRequest } from "../../utils/makeRequest"; -import { TDisplay, TDisplayInput } from "@formbricks/types/v1/displays"; export class DisplayAPI { private apiHost: string; + private environmentId: string; - constructor(baseUrl: string) { + constructor(baseUrl: string, environmentId: string) { this.apiHost = baseUrl; + this.environmentId = environmentId; } - async markDisplayedForPerson({ - surveyId, - personId, - }: TDisplayInput): Promise> { - return makeRequest(this.apiHost, "/api/v1/client/displays", "POST", { surveyId, personId }); + async create( + displayInput: Omit + ): Promise> { + return makeRequest(this.apiHost, `/api/v1/client/${this.environmentId}/displays`, "POST", displayInput); } - async markResponded({ displayId }: { displayId: string }): Promise> { - return makeRequest(this.apiHost, `/api/v1/client/displays/${displayId}/responded`, "POST"); + async update( + displayId: string, + displayInput: Omit + ): Promise> { + return makeRequest( + this.apiHost, + `/api/v1/client/${this.environmentId}/displays/${displayId}`, + "PUT", + displayInput + ); } } diff --git a/packages/api/src/api/client/index.ts b/packages/api/src/api/client/index.ts index 9df02a5e48..9f7085fc38 100644 --- a/packages/api/src/api/client/index.ts +++ b/packages/api/src/api/client/index.ts @@ -1,15 +1,24 @@ -import { ResponseAPI } from "./response"; -import { DisplayAPI } from "./display"; import { ApiConfig } from "../../types"; +import { ActionAPI } from "./action"; +import { DisplayAPI } from "./display"; +import { PeopleAPI } from "./people"; +import { ResponseAPI } from "./response"; +import { StorageAPI } from "./storage"; export class Client { response: ResponseAPI; display: DisplayAPI; + action: ActionAPI; + people: PeopleAPI; + storage: StorageAPI; constructor(options: ApiConfig) { - const { apiHost } = options; + const { apiHost, environmentId } = options; - this.response = new ResponseAPI(apiHost); - this.display = new DisplayAPI(apiHost); + this.response = new ResponseAPI(apiHost, environmentId); + this.display = new DisplayAPI(apiHost, environmentId); + this.action = new ActionAPI(apiHost, environmentId); + this.people = new PeopleAPI(apiHost, environmentId); + this.storage = new StorageAPI(apiHost, environmentId); } } diff --git a/packages/api/src/api/client/people.ts b/packages/api/src/api/client/people.ts new file mode 100644 index 0000000000..467d6a8399 --- /dev/null +++ b/packages/api/src/api/client/people.ts @@ -0,0 +1,31 @@ +import { Result } from "@formbricks/types/errorHandlers"; +import { NetworkError } from "@formbricks/types/errors"; +import { TPersonUpdateInput } from "@formbricks/types/people"; + +import { makeRequest } from "../../utils/makeRequest"; + +export class PeopleAPI { + private apiHost: string; + private environmentId: string; + + constructor(apiHost: string, environmentId: string) { + this.apiHost = apiHost; + this.environmentId = environmentId; + } + + async create(userId: string): Promise> { + return makeRequest(this.apiHost, `/api/v1/client/${this.environmentId}/people`, "POST", { + environmentId: this.environmentId, + userId, + }); + } + + async update(userId: string, personInput: TPersonUpdateInput): Promise> { + return makeRequest( + this.apiHost, + `/api/v1/client/${this.environmentId}/people/${userId}`, + "POST", + personInput + ); + } +} diff --git a/packages/api/src/api/client/response.ts b/packages/api/src/api/client/response.ts index 672bb9666f..f0c73072a6 100644 --- a/packages/api/src/api/client/response.ts +++ b/packages/api/src/api/client/response.ts @@ -1,39 +1,36 @@ +import { Result } from "@formbricks/types/errorHandlers"; +import { NetworkError } from "@formbricks/types/errors"; +import { TResponseInput, TResponseUpdateInput } from "@formbricks/types/responses"; + import { makeRequest } from "../../utils/makeRequest"; -import { NetworkError } from "@formbricks/types/v1/errors"; -import { Result } from "@formbricks/types/v1/errorHandlers"; -import { TResponse, TResponseInput, TResponseUpdateInput } from "@formbricks/types/v1/responses"; type TResponseUpdateInputWithResponseId = TResponseUpdateInput & { responseId: string }; export class ResponseAPI { private apiHost: string; + private environmentId: string; - constructor(apiHost: string) { + constructor(apiHost: string, environmentId: string) { this.apiHost = apiHost; + this.environmentId = environmentId; } - async create({ - surveyId, - personId, - finished, - data, - }: TResponseInput): Promise> { - return makeRequest(this.apiHost, "/api/v1/client/responses", "POST", { - surveyId, - personId, - finished, - data, - }); + async create( + responseInput: Omit + ): Promise> { + return makeRequest(this.apiHost, `/api/v1/client/${this.environmentId}/responses`, "POST", responseInput); } async update({ responseId, finished, data, - }: TResponseUpdateInputWithResponseId): Promise> { - return makeRequest(this.apiHost, `/api/v1/client/responses/${responseId}`, "PUT", { + ttc, + }: TResponseUpdateInputWithResponseId): Promise> { + return makeRequest(this.apiHost, `/api/v1/client/${this.environmentId}/responses/${responseId}`, "PUT", { finished, data, + ttc, }); } } diff --git a/packages/api/src/api/client/storage.ts b/packages/api/src/api/client/storage.ts new file mode 100644 index 0000000000..1bc9a75276 --- /dev/null +++ b/packages/api/src/api/client/storage.ts @@ -0,0 +1,86 @@ +interface UploadFileConfig { + allowedFileExtensions?: string[]; + surveyId?: string; +} + +export class StorageAPI { + private apiHost: string; + private environmentId: string; + + constructor(apiHost: string, environmentId: string) { + this.apiHost = apiHost; + this.environmentId = environmentId; + } + + async uploadFile( + file: File, + { allowedFileExtensions, surveyId }: UploadFileConfig | undefined = {} + ): Promise { + if (!(file instanceof Blob) || !(file instanceof File)) { + throw new Error(`Invalid file type. Expected Blob or File, but received ${typeof file}`); + } + + const payload = { + fileName: file.name, + fileType: file.type, + allowedFileExtensions, + surveyId, + }; + + const response = await fetch(`${this.apiHost}/api/v1/client/${this.environmentId}/storage`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + throw new Error(`Upload failed with status: ${response.status}`); + } + + const json = await response.json(); + + const { data } = json; + const { signedUrl, fileUrl, signingData, presignedFields } = data; + + let requestHeaders: Record = {}; + + if (signingData) { + const { signature, timestamp, uuid } = signingData; + + requestHeaders = { + "X-File-Type": file.type, + "X-File-Name": encodeURIComponent(file.name), + "X-Survey-ID": surveyId ?? "", + "X-Signature": signature, + "X-Timestamp": String(timestamp), + "X-UUID": uuid, + }; + } + + const formData = new FormData(); + + if (presignedFields) { + Object.keys(presignedFields).forEach((key) => { + formData.append(key, presignedFields[key]); + }); + } + + // Add the actual file to be uploaded + formData.append("file", file); + + const uploadResponse = await fetch(signedUrl, { + method: "POST", + ...(signingData ? { headers: requestHeaders } : {}), + body: formData, + }); + + if (!uploadResponse.ok) { + const uploadJson = await uploadResponse.json(); + throw new Error(`${uploadJson.message}`); + } + + return fileUrl; + } +} diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index db5dfb4a28..64e249a0c9 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -1,5 +1,5 @@ -import { ApiConfig } from "./types/index"; import { Client } from "./api/client"; +import { ApiConfig } from "./types/index"; export class FormbricksAPI { client: Client; diff --git a/packages/api/src/utils/makeRequest.ts b/packages/api/src/utils/makeRequest.ts index d4e225fe21..1c3ab25673 100644 --- a/packages/api/src/utils/makeRequest.ts +++ b/packages/api/src/utils/makeRequest.ts @@ -1,5 +1,6 @@ -import { Result, err, ok, wrapThrows } from "@formbricks/types/v1/errorHandlers"; -import { NetworkError } from "@formbricks/types/v1/errors"; +import { Result, err, ok, wrapThrows } from "@formbricks/types/errorHandlers"; +import { NetworkError } from "@formbricks/types/errors"; + import { ApiResponse } from "../types"; export async function makeRequest( diff --git a/packages/database/jsonTypes.ts b/packages/database/jsonTypes.ts index a239f8b8c2..25b32fc026 100644 --- a/packages/database/jsonTypes.ts +++ b/packages/database/jsonTypes.ts @@ -1,22 +1,24 @@ -import { TActionClassNoCodeConfig } from "@formbricks/types/v1/actionClasses"; -import { TIntegrationConfig } from "@formbricks/types/v1/integrations"; -import { TResponseData, TResponseMeta, TResponsePersonAttributes } from "@formbricks/types/v1/responses"; +import { TActionClassNoCodeConfig } from "@formbricks/types/actionClasses"; +import { TIntegrationConfig } from "@formbricks/types/integration"; +import { TResponseData, TResponseMeta, TResponsePersonAttributes } from "@formbricks/types/responses"; import { - TSurveyWelcomeCard, TSurveyClosedMessage, TSurveyHiddenFields, TSurveyProductOverwrites, TSurveyQuestions, TSurveySingleUse, + TSurveyStyling, TSurveyThankYouCard, TSurveyVerifyEmail, -} from "@formbricks/types/v1/surveys"; -import { TUserNotificationSettings } from "@formbricks/types/v1/users"; + TSurveyWelcomeCard, +} from "@formbricks/types/surveys"; +import { TTeamBilling } from "@formbricks/types/teams"; +import { TUserNotificationSettings } from "@formbricks/types/user"; declare global { namespace PrismaJson { - export type EventProperties = { [key: string]: string }; - export type EventClassNoCodeConfig = TActionClassNoCodeConfig; + export type ActionProperties = { [key: string]: string }; + export type ActionClassNoCodeConfig = TActionClassNoCodeConfig; export type IntegrationConfig = TIntegrationConfig; export type ResponseData = TResponseData; export type ResponseMeta = TResponseMeta; @@ -26,9 +28,11 @@ declare global { export type SurveyThankYouCard = TSurveyThankYouCard; export type SurveyHiddenFields = TSurveyHiddenFields; export type SurveyProductOverwrites = TSurveyProductOverwrites; + export type SurveyStyling = TSurveyStyling; export type SurveyClosedMessage = TSurveyClosedMessage; export type SurveySingleUse = TSurveySingleUse; export type SurveyVerifyEmail = TSurveyVerifyEmail; + export type TeamBilling = TTeamBilling; export type UserNotificationSettings = TUserNotificationSettings; } } diff --git a/packages/database/migrations/20231019160204_add_airtable_integration/migration.sql b/packages/database/migrations/20231019160204_add_airtable_integration/migration.sql new file mode 100644 index 0000000000..55ef1e08da --- /dev/null +++ b/packages/database/migrations/20231019160204_add_airtable_integration/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "IntegrationType" ADD VALUE 'airtable'; diff --git a/packages/database/migrations/20231020073124_pin_as_string/migration.sql b/packages/database/migrations/20231020073124_pin_as_string/migration.sql new file mode 100644 index 0000000000..8485643fe9 --- /dev/null +++ b/packages/database/migrations/20231020073124_pin_as_string/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Survey" ALTER COLUMN "pin" SET DATA TYPE TEXT; diff --git a/packages/database/migrations/20231020082319_add_azuread/migration.sql b/packages/database/migrations/20231020082319_add_azuread/migration.sql new file mode 100644 index 0000000000..0e3cb3bdcd --- /dev/null +++ b/packages/database/migrations/20231020082319_add_azuread/migration.sql @@ -0,0 +1,5 @@ +-- AlterEnum +ALTER TYPE "IdentityProvider" ADD VALUE 'azuread'; + +-- AlterTable +ALTER TABLE "Account" ADD COLUMN "ext_expires_in" INTEGER; diff --git a/packages/database/migrations/20231030105533_add_cascade_delete_to_integrations/migration.sql b/packages/database/migrations/20231030105533_add_cascade_delete_to_integrations/migration.sql new file mode 100644 index 0000000000..1ffad52bf6 --- /dev/null +++ b/packages/database/migrations/20231030105533_add_cascade_delete_to_integrations/migration.sql @@ -0,0 +1,5 @@ +-- DropForeignKey +ALTER TABLE "Integration" DROP CONSTRAINT "Integration_environmentId_fkey"; + +-- AddForeignKey +ALTER TABLE "Integration" ADD CONSTRAINT "Integration_environmentId_fkey" FOREIGN KEY ("environmentId") REFERENCES "Environment"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/database/migrations/20231030174314_add_billing_to_team/migration.sql b/packages/database/migrations/20231030174314_add_billing_to_team/migration.sql new file mode 100644 index 0000000000..b7c0531dfa --- /dev/null +++ b/packages/database/migrations/20231030174314_add_billing_to_team/migration.sql @@ -0,0 +1,14 @@ +/* + Warnings: + + - You are about to drop the column `plan` on the `Team` table. All the data in the column will be lost. + - You are about to drop the column `stripeCustomerId` on the `Team` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "Team" DROP COLUMN "plan", +DROP COLUMN "stripeCustomerId", +ADD COLUMN "billing" JSONB NOT NULL DEFAULT '{"stripeCustomerId": null, "features": {"inAppSurvey": {"status": "inactive", "unlimited": false}, "linkSurvey": {"status": "inactive", "unlimited": false}, "userTargeting": {"status": "inactive", "unlimited": false}}}'; + +-- DropEnum +DROP TYPE "Plan"; diff --git a/packages/database/migrations/20231102045537_adds_image_url_column_in_the_user_table/migration.sql b/packages/database/migrations/20231102045537_adds_image_url_column_in_the_user_table/migration.sql new file mode 100644 index 0000000000..67117c041c --- /dev/null +++ b/packages/database/migrations/20231102045537_adds_image_url_column_in_the_user_table/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "imageUrl" TEXT; diff --git a/packages/database/migrations/20231107145619_add_indexes/migration.sql b/packages/database/migrations/20231107145619_add_indexes/migration.sql new file mode 100644 index 0000000000..1bc5e5827c --- /dev/null +++ b/packages/database/migrations/20231107145619_add_indexes/migration.sql @@ -0,0 +1,71 @@ +-- CreateIndex +CREATE INDEX "Account_userId_idx" ON "Account"("userId"); + +-- CreateIndex +CREATE INDEX "ApiKey_environmentId_idx" ON "ApiKey"("environmentId"); + +-- CreateIndex +CREATE INDEX "AttributeClass_environmentId_idx" ON "AttributeClass"("environmentId"); + +-- CreateIndex +CREATE INDEX "Display_surveyId_idx" ON "Display"("surveyId"); + +-- CreateIndex +CREATE INDEX "Display_personId_idx" ON "Display"("personId"); + +-- CreateIndex +CREATE INDEX "Environment_productId_idx" ON "Environment"("productId"); + +-- CreateIndex +CREATE INDEX "Integration_environmentId_idx" ON "Integration"("environmentId"); + +-- CreateIndex +CREATE INDEX "Invite_teamId_idx" ON "Invite"("teamId"); + +-- CreateIndex +CREATE INDEX "Membership_userId_idx" ON "Membership"("userId"); + +-- CreateIndex +CREATE INDEX "Membership_teamId_idx" ON "Membership"("teamId"); + +-- CreateIndex +CREATE INDEX "Person_environmentId_idx" ON "Person"("environmentId"); + +-- CreateIndex +CREATE INDEX "Product_teamId_idx" ON "Product"("teamId"); + +-- CreateIndex +CREATE INDEX "Response_surveyId_created_at_idx" ON "Response"("surveyId", "created_at"); + +-- CreateIndex +CREATE INDEX "Response_surveyId_idx" ON "Response"("surveyId"); + +-- CreateIndex +CREATE INDEX "ResponseNote_responseId_idx" ON "ResponseNote"("responseId"); + +-- CreateIndex +CREATE INDEX "Survey_environmentId_idx" ON "Survey"("environmentId"); + +-- CreateIndex +CREATE INDEX "SurveyAttributeFilter_surveyId_idx" ON "SurveyAttributeFilter"("surveyId"); + +-- CreateIndex +CREATE INDEX "SurveyAttributeFilter_attributeClassId_idx" ON "SurveyAttributeFilter"("attributeClassId"); + +-- CreateIndex +CREATE INDEX "SurveyTrigger_surveyId_idx" ON "SurveyTrigger"("surveyId"); + +-- CreateIndex +CREATE INDEX "Tag_environmentId_idx" ON "Tag"("environmentId"); + +-- CreateIndex +CREATE INDEX "TagsOnResponses_responseId_idx" ON "TagsOnResponses"("responseId"); + +-- CreateIndex +CREATE INDEX "User_email_idx" ON "User"("email"); + +-- CreateIndex +CREATE INDEX "Webhook_environmentId_idx" ON "Webhook"("environmentId"); + +-- RenameIndex +ALTER INDEX "email_teamId_unique" RENAME TO "Invite_email_teamId_idx"; diff --git a/packages/database/migrations/20231109052945_restructure_session_action_person/migration.sql b/packages/database/migrations/20231109052945_restructure_session_action_person/migration.sql new file mode 100644 index 0000000000..ec71ceb81a --- /dev/null +++ b/packages/database/migrations/20231109052945_restructure_session_action_person/migration.sql @@ -0,0 +1,82 @@ +/* + Warnings: + + - You are about to drop the `Event` table. If the table is not empty, all the data it contains will be lost. + - You are about to drop the `Session` table. If the table is not empty, all the data it contains will be lost. + +*/ +-- DropForeignKey +ALTER TABLE "Event" DROP CONSTRAINT "Event_eventClassId_fkey"; + +-- DropForeignKey +ALTER TABLE "Event" DROP CONSTRAINT "Event_sessionId_fkey"; + +-- DropForeignKey +ALTER TABLE "Session" DROP CONSTRAINT "Session_personId_fkey"; + +-- DropTable +DROP TABLE "Event"; + +-- DropTable +DROP TABLE "Session"; + +ALTER TABLE "EventClass" RENAME TO "ActionClass"; + +-- AlterTable +ALTER TABLE "ActionClass" RENAME CONSTRAINT "EventClass_pkey" TO "ActionClass_pkey"; + +-- RenameForeignKey +ALTER TABLE "ActionClass" RENAME CONSTRAINT "EventClass_environmentId_fkey" TO "ActionClass_environmentId_fkey"; + +-- RenameIndex +ALTER INDEX "EventClass_name_environmentId_key" RENAME TO "ActionClass_name_environmentId_key"; + +-- CreateTable +CREATE TABLE "Action" ( + "id" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "actionClassId" TEXT NOT NULL, + "personId" TEXT NOT NULL, + "properties" JSONB NOT NULL DEFAULT '{}', + + CONSTRAINT "Action_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "Action" ADD CONSTRAINT "Action_actionClassId_fkey" FOREIGN KEY ("actionClassId") REFERENCES "ActionClass"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Action" ADD CONSTRAINT "Action_personId_fkey" FOREIGN KEY ("personId") REFERENCES "Person"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "SurveyTrigger" RENAME COLUMN "eventClassId" TO "actionClassId"; + +-- RenameForeignKey +ALTER TABLE "SurveyTrigger" RENAME CONSTRAINT "SurveyTrigger_eventClassId_fkey" TO "SurveyTrigger_actionClassId_fkey"; + +-- RenameIndex +ALTER INDEX "SurveyTrigger_surveyId_eventClassId_key" RENAME TO "SurveyTrigger_surveyId_actionClassId_key"; + +ALTER TYPE "EventType" RENAME TO "ActionType"; + +-- CreateIndex +CREATE INDEX "Action_personId_idx" ON "Action"("personId"); + +-- CreateIndex +CREATE INDEX "Action_actionClassId_idx" ON "Action"("actionClassId"); + +/* + Warnings: + + - A unique constraint covering the columns `[environmentId,userId]` on the table `Person` will be added. If there are existing duplicate values, this will fail. + +*/ +-- AlterTable +ALTER TABLE "Person" ADD COLUMN "userId" SERIAL NOT NULL; + +-- CreateIndex +CREATE UNIQUE INDEX "Person_environmentId_userId_key" ON "Person"("environmentId", "userId"); + +-- AlterTable +ALTER TABLE "Person" ALTER COLUMN "userId" DROP DEFAULT, +ALTER COLUMN "userId" SET DATA TYPE TEXT; +DROP SEQUENCE "Person_userId_seq"; diff --git a/packages/database/migrations/20231114143459_rename_branding/migration.sql b/packages/database/migrations/20231114143459_rename_branding/migration.sql new file mode 100644 index 0000000000..c70bb6cdf5 --- /dev/null +++ b/packages/database/migrations/20231114143459_rename_branding/migration.sql @@ -0,0 +1,9 @@ +/* + Warnings: + + - You are about to drop the column `formbricksSignature` on the `Product` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "Product" RENAME COLUMN "formbricksSignature" TO "linkSurveyBranding"; +ALTER TABLE "Product" ADD COLUMN "inAppSurveyBranding" BOOLEAN NOT NULL DEFAULT true; diff --git a/packages/database/migrations/20231116131301_add_types_to_wehbhook_source/migration.sql b/packages/database/migrations/20231116131301_add_types_to_wehbhook_source/migration.sql new file mode 100644 index 0000000000..a6a8932678 --- /dev/null +++ b/packages/database/migrations/20231116131301_add_types_to_wehbhook_source/migration.sql @@ -0,0 +1,18 @@ +-- 1. Rename the existing ENUM type. +ALTER TYPE "WehbhookSource" RENAME TO "TempWebhookSource"; + +-- 2. Create the new ENUM type. +CREATE TYPE "WebhookSource" AS ENUM ('user', 'zapier', 'make', 'n8n'); + +-- 3. Remove the default. +ALTER TABLE "Webhook" ALTER COLUMN "source" DROP DEFAULT; + +-- 4. Change the column type using the USING clause for casting. +ALTER TABLE "Webhook" +ALTER COLUMN "source" TYPE "WebhookSource" USING "source"::text::"WebhookSource"; + +-- 5. Add the default back. +ALTER TABLE "Webhook" ALTER COLUMN "source" SET DEFAULT 'user'; + +-- Optionally, if you want to drop the old ENUM type after verifying everything works: +DROP TYPE "TempWebhookSource"; diff --git a/packages/database/migrations/20231129143833_add_ttc_to_response/migration.sql b/packages/database/migrations/20231129143833_add_ttc_to_response/migration.sql new file mode 100644 index 0000000000..1c5acee9dd --- /dev/null +++ b/packages/database/migrations/20231129143833_add_ttc_to_response/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Response" ADD COLUMN "ttc" JSONB NOT NULL DEFAULT '{}'; diff --git a/packages/database/migrations/20231204180155_add_styling/migration.sql b/packages/database/migrations/20231204180155_add_styling/migration.sql new file mode 100644 index 0000000000..53f0b691d7 --- /dev/null +++ b/packages/database/migrations/20231204180155_add_styling/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Survey" ADD COLUMN "styling" JSONB; diff --git a/packages/database/migrations/20231207064643_add_notion_integration/migration.sql b/packages/database/migrations/20231207064643_add_notion_integration/migration.sql new file mode 100644 index 0000000000..c624d507f4 --- /dev/null +++ b/packages/database/migrations/20231207064643_add_notion_integration/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "IntegrationType" ADD VALUE 'notion'; diff --git a/packages/database/migrations/20240102132851_add_result_share_key_to_survey/migration.sql b/packages/database/migrations/20240102132851_add_result_share_key_to_survey/migration.sql new file mode 100644 index 0000000000..24308101ee --- /dev/null +++ b/packages/database/migrations/20240102132851_add_result_share_key_to_survey/migration.sql @@ -0,0 +1,11 @@ +/* + Warnings: + + - A unique constraint covering the columns `[resultShareKey]` on the table `Survey` will be added. If there are existing duplicate values, this will fail. + +*/ +-- AlterTable +ALTER TABLE "Survey" ADD COLUMN "resultShareKey" TEXT; + +-- CreateIndex +CREATE UNIQUE INDEX "Survey_resultShareKey_key" ON "Survey"("resultShareKey"); diff --git a/packages/database/package.json b/packages/database/package.json index f2552c683b..c84f42ecd6 100644 --- a/packages/database/package.json +++ b/packages/database/package.json @@ -25,7 +25,7 @@ "predev": "pnpm generate" }, "dependencies": { - "@prisma/client": "^5.4.2", + "@prisma/client": "^5.7.1", "@prisma/extension-accelerate": "^0.6.2", "dotenv-cli": "^7.3.0" }, @@ -33,9 +33,9 @@ "@formbricks/tsconfig": "workspace:*", "@formbricks/types": "workspace:*", "eslint-config-formbricks": "workspace:*", - "prisma": "^5.4.2", + "prisma": "^5.7.1", "prisma-dbml-generator": "^0.10.0", - "prisma-json-types-generator": "^3.0.2", + "prisma-json-types-generator": "^3.0.3", "zod": "^3.22.4", "zod-prisma": "^0.5.4" } diff --git a/packages/database/schema.prisma b/packages/database/schema.prisma index d4e5ec5c6d..d0b1c95153 100644 --- a/packages/database/schema.prisma +++ b/packages/database/schema.prisma @@ -31,9 +31,11 @@ enum PipelineTriggers { responseFinished } -enum WehbhookSource { +enum WebhookSource { user zapier + make + n8n } model Webhook { @@ -42,11 +44,13 @@ model Webhook { createdAt DateTime @default(now()) @map(name: "created_at") updatedAt DateTime @updatedAt @map(name: "updated_at") url String - source WehbhookSource @default(user) + source WebhookSource @default(user) environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade) environmentId String triggers PipelineTriggers[] surveyIds String[] + + @@index([environmentId]) } model Attribute { @@ -82,18 +86,23 @@ model AttributeClass { attributeFilters SurveyAttributeFilter[] @@unique([name, environmentId]) + @@index([environmentId]) } model Person { id String @id @default(cuid()) + userId String createdAt DateTime @default(now()) @map(name: "created_at") updatedAt DateTime @updatedAt @map(name: "updated_at") environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade) environmentId String responses Response[] - sessions Session[] attributes Attribute[] displays Display[] + actions Action[] + + @@unique([environmentId, userId]) + @@index([environmentId]) } model Response { @@ -109,6 +118,9 @@ model Response { /// @zod.custom(imports.ZResponseData) /// [ResponseData] data Json @default("{}") + /// @zod.custom(imports.ZResponseTtc) + /// [ResponseTtc] + ttc Json @default("{}") /// @zod.custom(imports.ZResponseMeta) /// [ResponseMeta] meta Json @default("{}") @@ -120,6 +132,8 @@ model Response { singleUseId String? @@unique([surveyId, singleUseId]) + @@index([surveyId, createdAt]) // to determine monthly response count + @@index([surveyId]) } model ResponseNote { @@ -133,6 +147,8 @@ model ResponseNote { text String isResolved Boolean @default(false) isEdited Boolean @default(false) + + @@index([responseId]) } model Tag { @@ -145,6 +161,7 @@ model Tag { environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade) @@unique([environmentId, name]) + @@index([environmentId]) } model TagsOnResponses { @@ -154,6 +171,7 @@ model TagsOnResponses { tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade) @@id([responseId, tagId]) + @@index([responseId]) } enum SurveyStatus { @@ -178,18 +196,22 @@ model Display { personId String? responseId String? @unique status DisplayStatus? + + @@index([surveyId]) + @@index([personId]) } model SurveyTrigger { - id String @id @default(cuid()) - createdAt DateTime @default(now()) @map(name: "created_at") - updatedAt DateTime @updatedAt @map(name: "updated_at") - survey Survey @relation(fields: [surveyId], references: [id], onDelete: Cascade) - surveyId String - eventClass EventClass @relation(fields: [eventClassId], references: [id], onDelete: Cascade) - eventClassId String + id String @id @default(cuid()) + createdAt DateTime @default(now()) @map(name: "created_at") + updatedAt DateTime @updatedAt @map(name: "updated_at") + survey Survey @relation(fields: [surveyId], references: [id], onDelete: Cascade) + surveyId String + actionClass ActionClass @relation(fields: [actionClassId], references: [id], onDelete: Cascade) + actionClassId String - @@unique([surveyId, eventClassId]) + @@unique([surveyId, actionClassId]) + @@index([surveyId]) } enum SurveyAttributeFilterCondition { @@ -209,6 +231,8 @@ model SurveyAttributeFilter { value String @@unique([surveyId, attributeClassId]) + @@index([surveyId]) + @@index([attributeClassId]) } enum SurveyType { @@ -265,62 +289,59 @@ model Survey { /// @zod.custom(imports.ZSurveyProductOverwrites) /// [SurveyProductOverwrites] productOverwrites Json? + /// @zod.custom(imports.ZSurveyStyling) + /// [SurveyStyling] + styling Json? /// @zod.custom(imports.ZSurveySingleUse) /// [SurveySingleUse] - singleUse Json? @default("{\"enabled\": false, \"isEncrypted\": true}") + singleUse Json? @default("{\"enabled\": false, \"isEncrypted\": true}") /// @zod.custom(imports.ZSurveyVerifyEmail) /// [SurveyVerifyEmail] verifyEmail Json? + pin String? + resultShareKey String? @unique - // PIN Protected Surveys - pin Int? + @@index([environmentId]) } -model Event { - id String @id @default(cuid()) - createdAt DateTime @default(now()) @map(name: "created_at") - eventClass EventClass? @relation(fields: [eventClassId], references: [id]) - eventClassId String? - session Session @relation(fields: [sessionId], references: [id], onDelete: Cascade) - sessionId String - /// @zod.custom(imports.ZEventProperties) - /// @zod.custom(imports.ZEventProperties) - /// [EventProperties] - properties Json @default("{}") -} - -enum EventType { +enum ActionType { code noCode automatic } -model EventClass { +model ActionClass { id String @id @default(cuid()) createdAt DateTime @default(now()) @map(name: "created_at") updatedAt DateTime @updatedAt @map(name: "updated_at") name String description String? - type EventType - events Event[] + type ActionType /// @zod.custom(imports.ZActionClassNoCodeConfig) - /// [EventClassNoCodeConfig] + /// [ActionClassNoCodeConfig] noCodeConfig Json? environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade) environmentId String surveys SurveyTrigger[] + actions Action[] @@unique([name, environmentId]) } -model Session { - id String @id @default(cuid()) - createdAt DateTime @default(now()) @map(name: "created_at") - updatedAt DateTime @updatedAt @map(name: "updated_at") - expiresAt DateTime @default(now()) - person Person @relation(fields: [personId], references: [id], onDelete: Cascade) - personId String - events Event[] +model Action { + id String @id @default(cuid()) + createdAt DateTime @default(now()) @map(name: "created_at") + actionClass ActionClass @relation(fields: [actionClassId], references: [id], onDelete: Cascade) + actionClassId String + person Person @relation(fields: [personId], references: [id], onDelete: Cascade) + personId String + /// @zod.custom(imports.ZActionProperties) + /// @zod.custom(imports.ZActionProperties) + /// [ActionProperties] + properties Json @default("{}") + + @@index([personId]) + @@index([actionClassId]) } enum EnvironmentType { @@ -330,6 +351,8 @@ enum EnvironmentType { enum IntegrationType { googleSheets + notion + airtable } model Integration { @@ -339,9 +362,10 @@ model Integration { /// @zod.custom(imports.ZIntegrationConfig) /// [IntegrationConfig] config Json - environment Environment @relation(fields: [environmentId], references: [id]) + environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade) @@unique([type, environmentId]) + @@index([environmentId]) } model Environment { @@ -354,12 +378,14 @@ model Environment { widgetSetupCompleted Boolean @default(false) surveys Survey[] people Person[] - eventClasses EventClass[] + actionClasses ActionClass[] attributeClasses AttributeClass[] apiKeys ApiKey[] webhooks Webhook[] tags Tag[] integration Integration[] + + @@index([productId]) } enum WidgetPlacement { @@ -381,29 +407,27 @@ model Product { brandColor String @default("#64748b") highlightBorderColor String? recontactDays Int @default(7) - formbricksSignature Boolean @default(true) + linkSurveyBranding Boolean @default(true) // Determines if the survey branding should be displayed in link surveys + inAppSurveyBranding Boolean @default(true) // Determines if the survey branding should be displayed in in-app surveys placement WidgetPlacement @default(bottomRight) clickOutsideClose Boolean @default(true) darkOverlay Boolean @default(false) @@unique([teamId, name]) -} - -enum Plan { - free - pro + @@index([teamId]) } model Team { - id String @id @default(cuid()) - createdAt DateTime @default(now()) @map(name: "created_at") - updatedAt DateTime @updatedAt @map(name: "updated_at") - name String - memberships Membership[] - products Product[] - plan Plan @default(free) - stripeCustomerId String? - invites Invite[] + id String @id @default(cuid()) + createdAt DateTime @default(now()) @map(name: "created_at") + updatedAt DateTime @updatedAt @map(name: "updated_at") + name String + memberships Membership[] + products Product[] + /// @zod.custom(imports.ZTeamBilling) + /// [TeamBilling] + billing Json @default("{\"stripeCustomerId\": null, \"features\": {\"inAppSurvey\": {\"status\": \"inactive\", \"unlimited\": false}, \"linkSurvey\": {\"status\": \"inactive\", \"unlimited\": false}, \"userTargeting\": {\"status\": \"inactive\", \"unlimited\": false}}}") + invites Invite[] } enum MembershipRole { @@ -423,6 +447,8 @@ model Membership { role MembershipRole @@id([userId, teamId]) + @@index([userId]) + @@index([teamId]) } model Invite { @@ -440,7 +466,8 @@ model Invite { expiresAt DateTime role MembershipRole @default(admin) - @@index([email, teamId], name: "email_teamId_unique") + @@index([email, teamId]) + @@index([teamId]) } model ApiKey { @@ -451,12 +478,15 @@ model ApiKey { hashedKey String @unique() environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade) environmentId String + + @@index([environmentId]) } enum IdentityProvider { email github google + azuread } model Account { @@ -471,12 +501,14 @@ model Account { access_token String? @db.Text refresh_token String? @db.Text expires_at Int? + ext_expires_in Int? token_type String? scope String? id_token String? @db.Text session_state String? @@unique([provider, providerAccountId]) + @@index([userId]) } enum Role { @@ -511,6 +543,7 @@ model User { name String? email String @unique emailVerified DateTime? @map(name: "email_verified") + imageUrl String? twoFactorSecret String? twoFactorEnabled Boolean @default(false) backupCodes String? @@ -530,6 +563,8 @@ model User { /// @zod.custom(imports.ZUserNotificationSettings) /// [UserNotificationSettings] notificationSettings Json @default("{}") + + @@index([email]) } model ShortUrl { diff --git a/packages/database/src/index.ts b/packages/database/src/index.ts index d6ca9a4a55..16ace53143 100644 --- a/packages/database/src/index.ts +++ b/packages/database/src/index.ts @@ -1,2 +1,3 @@ import "../jsonTypes"; + export * from "./client"; diff --git a/packages/database/src/jestClient.ts b/packages/database/src/jestClient.ts new file mode 100644 index 0000000000..d10b230d73 --- /dev/null +++ b/packages/database/src/jestClient.ts @@ -0,0 +1,15 @@ +import { PrismaClient } from "@prisma/client"; +import { DeepMockProxy, mockDeep, mockReset } from "jest-mock-extended"; + +import { prisma } from "./client"; + +jest.mock("./client", () => ({ + __esModule: true, + prisma: mockDeep(), +})); + +export const prismaMock = prisma as unknown as DeepMockProxy; + +beforeEach(() => { + mockReset(prismaMock); +}); diff --git a/packages/database/zod-utils.ts b/packages/database/zod-utils.ts index 2923a4949d..2d4c51456d 100644 --- a/packages/database/zod-utils.ts +++ b/packages/database/zod-utils.ts @@ -1,10 +1,15 @@ import z from "zod"; -export const ZEventProperties = z.record(z.string()); -export { ZActionClassNoCodeConfig } from "@formbricks/types/v1/actionClasses"; -export { ZIntegrationConfig } from "@formbricks/types/v1/integrations"; +export const ZActionProperties = z.record(z.string()); +export { ZActionClassNoCodeConfig } from "@formbricks/types/actionClasses"; +export { ZIntegrationConfig } from "@formbricks/types/integration"; -export { ZResponseData, ZResponsePersonAttributes, ZResponseMeta } from "@formbricks/types/v1/responses"; +export { + ZResponseData, + ZResponsePersonAttributes, + ZResponseMeta, + ZResponseTtc, +} from "@formbricks/types/responses"; export { ZSurveyWelcomeCard, @@ -13,8 +18,10 @@ export { ZSurveyHiddenFields, ZSurveyClosedMessage, ZSurveyProductOverwrites, + ZSurveyStyling, ZSurveyVerifyEmail, ZSurveySingleUse, -} from "@formbricks/types/v1/surveys"; +} from "@formbricks/types/surveys"; -export { ZUserNotificationSettings } from "@formbricks/types/v1/users"; +export { ZTeamBilling } from "@formbricks/types/teams"; +export { ZUserNotificationSettings } from "@formbricks/types/user"; diff --git a/packages/ee/RoleManagement/components/AddMemberRole.tsx b/packages/ee/RoleManagement/components/AddMemberRole.tsx new file mode 100644 index 0000000000..2c9fbeb1ae --- /dev/null +++ b/packages/ee/RoleManagement/components/AddMemberRole.tsx @@ -0,0 +1,50 @@ +import { Control, Controller } from "react-hook-form"; + +import { Label } from "@formbricks/ui/Label"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from "@formbricks/ui/Select"; + +enum MembershipRole { + Admin = "admin", + Editor = "editor", + Developer = "developer", + Viewer = "viewer", +} + +type AddMemberRole = { + control: Control<{ name: string; email: string; role: MembershipRole }, any>; +}; + +export const AddMemberRole = ({ control }: AddMemberRole) => { + return ( + ( +
+ + +
+ )} + /> + ); +}; diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/members/components/EditMemberships/MembershipRole.tsx b/packages/ee/RoleManagement/components/EditMembershipRole.tsx similarity index 84% rename from apps/web/app/(app)/environments/[environmentId]/settings/members/components/EditMemberships/MembershipRole.tsx rename to packages/ee/RoleManagement/components/EditMembershipRole.tsx index 2270e4a126..a3e653cc39 100644 --- a/apps/web/app/(app)/environments/[environmentId]/settings/members/components/EditMemberships/MembershipRole.tsx +++ b/packages/ee/RoleManagement/components/EditMembershipRole.tsx @@ -1,13 +1,14 @@ "use client"; -import TransferOwnershipModal from "@/app/(app)/environments/[environmentId]/settings/members/components/TransferOwnershipModal"; -import { - transferOwnershipAction, - updateInviteAction, - updateMembershipAction, -} from "@/app/(app)/environments/[environmentId]/settings/members/actions"; -import { MEMBERSHIP_ROLES, capitalizeFirstLetter } from "@/app/lib/utils"; -import { TMembershipRole } from "@formbricks/types/v1/memberships"; +import { ChevronDownIcon } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import toast from "react-hot-toast"; + +import { capitalizeFirstLetter } from "@formbricks/lib/strings"; +import { TMembershipRole } from "@formbricks/types/memberships"; +import { Badge } from "@formbricks/ui/Badge"; +import { Button } from "@formbricks/ui/Button"; import { DropdownMenu, DropdownMenuContent, @@ -15,12 +16,9 @@ import { DropdownMenuRadioItem, DropdownMenuTrigger, } from "@formbricks/ui/DropdownMenu"; -import { Button } from "@formbricks/ui/Button"; -import { Badge } from "@formbricks/ui/Badge"; -import { ChevronDownIcon } from "lucide-react"; -import { useRouter } from "next/navigation"; -import { useState } from "react"; -import toast from "react-hot-toast"; + +import { transferOwnershipAction, updateInviteAction, updateMembershipAction } from "../lib/actions"; +import TransferOwnershipModal from "./TransferOwnershipModal"; interface Role { isAdminOrOwner: boolean; @@ -34,7 +32,7 @@ interface Role { currentUserRole: string; } -export default function MembershipRole({ +export const EditMembershipRole = ({ isAdminOrOwner, memberRole, teamId, @@ -44,7 +42,7 @@ export default function MembershipRole({ memberAccepted, inviteId, currentUserRole, -}: Role) { +}: Role) => { const router = useRouter(); const [loading, setLoading] = useState(false); const [isTransferOwnershipModalOpen, setTransferOwnershipModalOpen] = useState(false); @@ -82,7 +80,7 @@ export default function MembershipRole({ setTransferOwnershipModalOpen(false); toast.success("Ownership transferred successfully"); router.refresh(); - } catch (err) { + } catch (err: any) { toast.error(`Error: ${err.message}`); setLoading(false); setTransferOwnershipModalOpen(false); @@ -98,11 +96,12 @@ export default function MembershipRole({ }; const getMembershipRoles = () => { + const roles = ["owner", "admin", "editor", "developer", "viewer"]; if (currentUserRole === "owner" && memberAccepted) { - return Object.keys(MEMBERSHIP_ROLES); + return roles; } - return Object.keys(MEMBERSHIP_ROLES).filter((role) => role !== "OWNER"); + return roles.filter((role) => role !== "owner"); }; if (isAdminOrOwner) { @@ -113,7 +112,7 @@ export default function MembershipRole({