Compare commits

...

55 Commits

Author SHA1 Message Date
Dhruwang
7616133a25 tweaks 2024-11-08 15:18:33 +05:30
Dhruwang
60139afd81 merged main 2024-11-08 15:14:05 +05:30
Dhruwang Jariwala
5633499834 fix: authclient error and delete integration issue (#3463)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2024-11-08 08:43:33 +00:00
Piyush Gupta
88847a153b fix: resolve recalls properly in document (#3943)
Co-authored-by: Dhruwang Jariwala <67850763+Dhruwang@users.noreply.github.com>
Co-authored-by: Matti Nannt <mail@matthiasnannt.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2024-11-08 08:25:02 +00:00
dependabot[bot]
3e7c3a45c3 chore(deps): bump the npm_and_yarn group across 2 directories with 1 update (#4144)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2024-11-08 07:34:38 +00:00
Piyush Gupta
1af1a92fec feat: granular team roles (#3975)
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com>
Co-authored-by: Johannes <johannes@formbricks.com>
2024-11-08 06:03:14 +00:00
Dhruwang Jariwala
7b38923b7d fix: survey status cron (#4252) 2024-11-07 08:40:43 +00:00
Matti Nannt
38500e1d79 fix: cal.com question not loading properly (#4251) 2024-11-07 09:39:52 +01:00
Vardhaman Bhandari
2e82fc3ead fix: setting preview form data resetting (#4182)
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2024-11-06 05:38:35 +00:00
Dhruwang Jariwala
e88ae4aa3d fix: toast translation (#4228) 2024-11-06 05:27:55 +00:00
Dhruwang Jariwala
5f55c922dc fix: templates (#4227) 2024-11-06 04:42:31 +00:00
Dhruwang Jariwala
fc3c044e00 fix: suid screen getting stuck (#4229) 2024-11-06 04:39:07 +00:00
Dhruwang Jariwala
bee0ab07b4 fix: person response page and copy link transalation issue (#4239)
Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com>
2024-11-05 22:55:20 +00:00
Gourav Thakur
0c37956943 fix: Delete account to signup page (#4162)
Co-authored-by: Matti Nannt <mail@matthiasnannt.com>
2024-11-05 07:27:32 +00:00
Johannes
74bd40e0ff feat: Update Insight category, document sentiment and archive insights (#4038)
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2024-11-05 07:07:27 +00:00
Matti Nannt
31c3f9730e fix: github issue template linting issues (#4234) 2024-11-04 16:49:55 +01:00
Matti Nannt
da8e7c1870 chore: Add CodeQL (#4233) 2024-11-04 16:27:13 +01:00
Matti Nannt
ac0ec1fbcd chore: update issue templates (#4230) 2024-11-04 16:25:54 +01:00
Johannes
18be650561 fix: onboarding template issue (#4221)
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2024-11-03 04:40:40 +00:00
Johannes
5977fa6f80 fix: DE translations (#4216) 2024-11-02 19:14:14 +00:00
Matti Nannt
1e2833b742 docs: remove links to discontinued examples repo (#4212) 2024-11-01 17:13:50 +01:00
Sai Suhas Sawant
f18b030ebf fix: date select highlight fix (#4208)
Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com>
2024-11-01 02:28:47 +00:00
Aditya Deshlahre
ef454d8140 docs: replacing old UI image to New on Best Practices (#4192)
Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com>
2024-10-31 23:12:50 +00:00
Harsh Shrikant Bhat
1ea391e45b docs: Add quiz guide in the docs (#4180) 2024-10-31 23:02:36 +00:00
Dhruwang Jariwala
b3e6e8d5d0 feat: multi language UI (#3133)
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2024-10-31 08:23:57 +00:00
Sai Suhas Sawant
705f55176f fix: date picker ui-fix (#3382)
Co-authored-by: Jonas Höbenreich <64426524+jonas-hoebenreich@users.noreply.github.com>
Co-authored-by: Vinay Kumar Maheshwaram <54681400+sudovinay01@users.noreply.github.com>
Co-authored-by: Sharad Kushwaha <123795629+SharadK10@users.noreply.github.com>
Co-authored-by: Matti Nannt <mail@matthiasnannt.com>
Co-authored-by: Chase Nelson <Chase.a.nelson@gmail.com>
Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com>
Co-authored-by: Johannes <johannes@formbricks.com>
2024-10-31 04:07:42 +00:00
Raj Gupta
297f349b45 fix: shiki package update from version 0.14.7 to 1.22.0 (#3542)
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com>
2024-10-30 20:49:34 -07:00
DivyanshuLohani
91b6a9e008 fix: Stop creation of empty name segments on people page (#4028)
Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com>
2024-10-31 03:34:12 +00:00
Aleksey
189dc52ee9 fix: mobile delay before showing survey doesn't work on react native (#3961)
Co-authored-by: Dhruwang Jariwala <67850763+Dhruwang@users.noreply.github.com>
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com>
2024-10-31 03:23:08 +00:00
Jonas Höbenreich
280a9a439b feat: add 4 new survey templates (#4018)
Co-authored-by: Bhavesh Mishra <69065938+thefool76@users.noreply.github.com>
Co-authored-by: Johannes <johannes@formbricks.com>
2024-10-31 03:11:44 +00:00
Ikko Eltociear Ashimine
bc844bbb1f chore: update SurveyDropdownMenu.tsx (#4076)
Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com>
2024-10-31 02:38:31 +00:00
Teddy ASSIH
ea2d6de9a7 feat: oss.gg: add new background video (#4123)
Co-authored-by: Johannes <johannes@formbricks.com>
2024-10-31 02:33:44 +00:00
Aditya Deshlahre
e09ab1dcbe feat: career & professionl development survey (#4092)
Co-authored-by: Johannes <johannes@formbricks.com>
2024-10-31 02:28:19 +00:00
Bhavesh Mishra
e1e04517a9 fix: Fixed the animation bug on the site (#4110)
Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com>
Co-authored-by: Johannes <johannes@formbricks.com>
2024-10-31 01:48:57 +00:00
Piyush Gupta
06026b6922 fix: error handling in insight generation (#4075)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2024-10-29 14:14:05 +00:00
Dishant Yadav
19a3faadce fix: Direct Link in Docs don't open Navigation Submenu (#3561)
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2024-10-29 10:55:37 +00:00
Bhavesh Mishra
33543f59f8 feat: Added longterm retention check in template (#4061)
Co-authored-by: Johannes <johannes@formbricks.com>
2024-10-28 03:56:04 +00:00
DivyanshuLohani
47826a45aa fix: open specific nav group on docs page on first page load (#4034)
Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com>
2024-10-26 23:16:04 +00:00
Aditya Deshlahre
6f043ec16e docs: add image or video to question (#4041)
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com>
2024-10-26 23:15:44 +00:00
Bhavesh Mishra
c2703788ae feat: oss.gg: add new background video (#4051)
Co-authored-by: Johannes <johannes@formbricks.com>
2024-10-26 22:13:36 +00:00
Aditya Deshlahre
ef7df0fc77 fix: added webhook url test for a valid URL (#4007)
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com>
2024-10-25 21:18:38 +00:00
Jonas Höbenreich
cdb8199199 feat: oss.gg: add new background video (#4017) 2024-10-25 21:08:20 +00:00
Piyush Gupta
b0ded570ff fix: inconsistencies in survey summary (#4032)
Co-authored-by: Johannes <johannes@formbricks.com>
2024-10-25 16:53:55 +00:00
Piyush Gupta
5c0b29eed4 fix: summary calculation in multi choice questions (#4022)
Co-authored-by: Johannes <johannes@formbricks.com>
2024-10-25 09:17:06 +00:00
Piyush Gupta
8e16d8daf6 fix: adds CSP header (#3754) 2024-10-25 08:58:59 +00:00
Johannes
deea760a17 fix: animate tweaks (#4015) 2024-10-24 20:02:18 -07:00
Matti Nannt
f56f08e3c1 docs: fix last occurence of old sdk call from docs (#4016) 2024-10-24 22:04:03 +02:00
Matti Nannt
5daeab6554 chore: remove attributes from identify call (#4012)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2024-10-24 17:16:56 +00:00
Dhruwang
19e5865d05 fix: backward compatibility 2024-10-17 12:08:53 +05:30
Dhruwang
6c5c27f571 Merge branch 'main' of https://github.com/merteroglu/formbricks into merteroglu/main 2024-10-16 17:48:30 +05:30
Dhruwang Jariwala
c12fb1a9f8 Merge branch 'main' into main 2024-10-16 17:46:00 +05:30
Dhruwang
526439def3 Merge branch 'main' of https://github.com/merteroglu/formbricks into merteroglu/main 2024-10-04 11:45:19 +05:30
Dhruwang
4e01ac211f Merge branch 'main' of https://github.com/Dhruwang/formbricks into merteroglu/main 2024-10-04 11:44:48 +05:30
Dhruwang Jariwala
f2f3ff6d46 Merge branch 'main' into main 2024-10-04 11:44:41 +05:30
Mert Eroğlu
b332cf12ca implement new slack ep 2024-09-29 23:21:30 +03:00
802 changed files with 30942 additions and 10830 deletions

View File

@@ -1,11 +1,11 @@
{
"$schema": "https://unpkg.com/@changesets/config@2.2.0/schema.json",
"access": "public",
"baseBranch": "main",
"changelog": "@changesets/cli/changelog",
"commit": false,
"fixed": [],
"ignore": ["@formbricks/demo", "@formbricks/web"],
"linked": [],
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": ["@formbricks/demo", "@formbricks/web"]
"updateInternalDependencies": "patch"
}

View File

@@ -2,11 +2,6 @@
// 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",
// Configure tool-specific properties.
"customizations": {
// Configure properties specific to VS Code.
@@ -16,14 +11,18 @@
}
},
"dockerComposeFile": "docker-compose.yml",
// 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": "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 && sed -i '/^CRON_SECRET=/c\\CRON_SECRET='$(openssl rand -hex 32) .env && pnpm install && pnpm db:migrate:dev",
"name": "Node.js & PostgreSQL",
"postAttachCommand": "pnpm dev --filter=@formbricks/web... --filter=@formbricks/demo...",
// 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 && sed -i '/^CRON_SECRET=/c\\CRON_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"
"remoteUser": "node",
"service": "app",
"workspaceFolder": "/workspace"
}

View File

@@ -157,7 +157,7 @@ ENTERPRISE_LICENSE_KEY=
# Insert an existing organization 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_ORGANIZATION_ID=
# DEFAULT_ORGANIZATION_ROLE=admin
# DEFAULT_ORGANIZATION_ROLE=owner
# Send new users to customer.io
# CUSTOMER_IO_API_KEY=

View File

@@ -1,81 +1,25 @@
name: Bug report
description: "Found a bug? Please fill out the sections below. \U0001F44D"
title: "[BUG]"
labels: bug
assignees: []
labels:
- bug
body:
- type: textarea
id: issue-summary
attributes:
label: Issue Summary
description: A summary of the issue. This needs to be a clear detailed-rich summary.
validations:
required: true
- type: textarea
id: steps-to-reproduce
attributes:
label: Steps to Reproduce
value: |
1. (for example) Went to ...
2. Clicked on...
3. ...
validations:
required: true
- type: textarea
id: expected-behavior
attributes:
label: Expected behavior
description: A clear and concise description of what you expected to happen.
validations:
required: true
- type: textarea
id: other-information
attributes:
label: Other information
validations:
required: false
- type: textarea
id: screenshots
attributes:
label: Screenshots
description: If applicable, add screenshots to help explain your problem.
validations:
required: false
- type: checkboxes
id: environment
attributes:
label: Environment
options:
- label: Formbricks Cloud (app.formbricks.com)
- label: Self-hosted Formbricks
- type: textarea
id: desktop-version
attributes:
label: Desktop (please complete the following information)
description: |
examples:
- **OS**: [e.g. iOS]
- **Browser**: [e.g. chrome, safari]
- **Version**: [e.g. 22]
value: |
- OS:
- Node:
- npm:
render: markdown
validations:
required: true
- type: markdown
id: nodejs-version
attributes:
value: |
#### Node.JS version
[e.g. v18.15.0]
- type: markdown
id: anything-else
attributes:
value: |
#### Anything else?
- Screen recording, console logs, network requests: You can make a recording with [Loom](https://www.loom.com).
- Anything else that you think could be an issue?
- type: textarea
id: issue-summary
attributes:
label: Issue Summary
description: A summary of the issue. This needs to be a clear detailed-rich summary.
validations:
required: true
- type: textarea
id: other-information
attributes:
label: Other information (incl. screenshots, Formbricks version, steps to reproduce,...)
validations:
required: false
- type: dropdown
id: environment
attributes:
label: Your Environment
options:
- Formbricks Cloud (app.formbricks.com)
- Self-hosted Formbricks

View File

@@ -1,5 +1,5 @@
blank_issues_enabled: false
contact_links:
- name: Questions
url: https://formbricks.com/discord
about: Ask a general question about the project on our Discord server
url: https://github.com/formbricks/formbricks/discussions
about: Need help selfhosting or ask a general question about the project? Open a discussion

View File

@@ -1,8 +1,7 @@
name: Feature request
description: "Suggest an idea for this project \U0001F680"
title: "[FEATURE]"
labels: enhancement
assignees: []
labels:
- enhancement
body:
- type: textarea
id: problem-description
@@ -18,13 +17,6 @@ body:
description: A clear and concise description of what you want to happen.
validations:
required: true
- type: textarea
id: alternate-solution-description
attributes:
label: Describe alternatives you've considered
description: A clear and concise description of any alternative solutions or features you've considered.
validations:
required: false
- type: textarea
id: additional-context
attributes:
@@ -33,15 +25,9 @@ body:
validations:
required: false
- type: markdown
id: formbricks-info
attributes:
value: |
### How we code at Formbricks 🤓
### Additional resources 🤓
- Follow Best Practices lined out in our [Contributor Docs](https://formbricks.com/docs/contributing/how-we-code)
- First time: Please read our [introductory blog post](https://formbricks.com/blog/join-the-formtribe)
- All UI components are in the package `formbricks/ui`
- Run `pnpm go` to find a demo app to test in-app surveys at `localhost:3002`
- Everything is type-safe.
- We use **chatGPT** to help refactor code.
- Check out our [Contributor Docs](https://formbricks.com/docs/developer-docs/contributing/get-started)
- Anything unclear? [Ask in Discord](https://formbricks.com/discord)

View File

@@ -1,33 +0,0 @@
name: oss.gg hack submission 🕹️
description: "Submit your contribution for the for the oss.gg hackathon"
title: "[🕹️]"
labels: 🕹️ oss.gg, player submission, hacktoberfest
assignees: []
body:
- type: textarea
id: contribution-name
attributes:
label: What side quest or challenge are you solving?
description: Add the name of the side quest or challenge.
validations:
required: true
- type: textarea
id: points
attributes:
label: Points
description: How many points are assigned to this contribution?
validations:
required: true
- type: textarea
id: description
attributes:
label: Description
description: What's the task your performed?
validations:
- type: textarea
id: proof
attributes:
label: Provide proof that you've completed the task
description: Screenshots, loom recordings, links to the content you shared or interacted with.
validations:
required: true

View File

@@ -4,7 +4,7 @@
<!-- Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change. -->
Fixes # (issue)
Fixes #(issue)
<!-- Please provide a screenshots or a loom video for visual changes to speed up reviews
Loom Video: https://www.loom.com/

92
.github/workflows/codeql.yml vendored Normal file
View File

@@ -0,0 +1,92 @@
# For most projects, this workflow file will not need changing; you simply need
# to commit it to your repository.
#
# You may wish to alter this file to override the set of languages analyzed,
# or to provide custom queries or build logic.
#
# ******** NOTE ********
# We have attempted to detect the languages in your repository. Please check
# the `language` matrix defined below to confirm you have the correct set of
# supported CodeQL languages.
#
name: "CodeQL Advanced"
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
schedule:
- cron: '17 1 * * 1'
jobs:
analyze:
name: Analyze (${{ matrix.language }})
# Runner size impacts CodeQL analysis time. To learn more, please see:
# - https://gh.io/recommended-hardware-resources-for-running-codeql
# - https://gh.io/supported-runners-and-hardware-resources
# - https://gh.io/using-larger-runners (GitHub.com only)
# Consider using larger runners or machines with greater resources for possible analysis time improvements.
runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
permissions:
# required for all workflows
security-events: write
# required to fetch internal or private CodeQL packs
packages: read
# only required for workflows in private repositories
actions: read
contents: read
strategy:
fail-fast: false
matrix:
include:
- language: javascript-typescript
build-mode: none
# CodeQL supports the following values keywords for 'language': 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift'
# Use `c-cpp` to analyze code written in C, C++ or both
# Use 'java-kotlin' to analyze code written in Java, Kotlin or both
# Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both
# To learn more about changing the languages that are analyzed or customizing the build mode for your analysis,
# see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning.
# If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how
# your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages
steps:
- name: Checkout repository
uses: actions/checkout@v4
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
# queries: security-extended,security-and-quality
# If the analyze step fails for one of the languages you are analyzing with
# "We were unable to automatically build your code", modify the matrix above
# to set the build mode to "manual" for that language. Then modify this step
# to build your code.
# Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
- if: matrix.build-mode == 'manual'
shell: bash
run: |
echo 'If you are using a "manual" build mode for one or more of the' \
'languages you are analyzing, replace this with the commands to build' \
'your code, for example:'
echo ' make bootstrap'
echo ' make release'
exit 1
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
with:
category: "/language:${{matrix.language}}"

View File

@@ -4,9 +4,9 @@ on:
workflow_dispatch:
# "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:
# Runs At 00:00. (see https://crontab.guru)
# - cron: "0 0 * * *"
schedule:
# Runs "At 00:00." (see https://crontab.guru)
- cron: "0 0 * * *"
jobs:
cron-weeklySummary:
env:

View File

@@ -2,5 +2,10 @@ const baseConfig = require("./packages/config-prettier/prettier-preset");
module.exports = {
...baseConfig,
plugins: ["@trivago/prettier-plugin-sort-imports", "prettier-plugin-tailwindcss"],
plugins: [
"@trivago/prettier-plugin-sort-imports",
"prettier-plugin-tailwindcss",
"prettier-plugin-sort-json",
],
jsonRecursiveSort: true,
};

18
.vscode/launch.json vendored
View File

@@ -1,21 +1,21 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Launch localhost:3002",
"type": "firefox",
"request": "launch",
"reAttach": true,
"request": "launch",
"type": "firefox",
"url": "http://localhost:3002/",
"webRoot": "${workspaceFolder}"
},
{
"name": "Attach",
"type": "firefox",
"request": "attach"
"request": "attach",
"type": "firefox"
}
]
],
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0"
}

View File

@@ -1,4 +1,4 @@
{
"typescript.tsdk": "node_modules/typescript/lib",
"typescript.preferences.importModuleSpecifier": "non-relative"
"typescript.preferences.importModuleSpecifier": "non-relative",
"typescript.tsdk": "node_modules/typescript/lib"
}

View File

@@ -51,13 +51,13 @@ In the interest of responsibly managing vulnerabilities, please adhere to the fo
> Do not reveal the problem to others until it has been resolved.
1. **Send a Detailed Report**:
- Address emails to [security@formbricks.com](mailto:security@formbricks.com).
- Raise a security report on [Github](https://github.com/formbricks/formbricks/issues/new/choose) or send an email to [security@formbricks.com](mailto:security@formbricks.com).
- Include:
- Problem description.
- Detailed, reproducible steps, with screenshots where possible.
- Affected version(s).
- Known possible mitigations.
- Your Discord username or preferred contact method.
- Your preferred contact method.
2. **Acknowledgement of Receipt**:
- Our security team will acknowledge receipt and provide an initial response within 48 hours.
- Following verification of the vulnerability and the fix, a release plan will be formulated, with the fix deployed between 7 to 28 days, depending on the severity and complexity.

View File

@@ -1,32 +1,32 @@
{
"expo": {
"name": "react-native-demo",
"slug": "react-native-demo",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "light",
"splash": {
"image": "./assets/splash.png",
"resizeMode": "contain",
"backgroundColor": "#ffffff"
},
"jsEngine": "hermes",
"assetBundlePatterns": ["**/*"],
"ios": {
"supportsTablet": true,
"infoPlist": {
"NSCameraUsageDescription": "Take pictures for certain activities.",
"NSPhotoLibraryUsageDescription": "Select pictures for certain activities.",
"NSMicrophoneUsageDescription": "Need microphone access for recording videos."
}
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#ffffff"
"backgroundColor": "#ffffff",
"foregroundImage": "./assets/adaptive-icon.png"
}
},
"assetBundlePatterns": ["**/*"],
"icon": "./assets/icon.png",
"ios": {
"infoPlist": {
"NSCameraUsageDescription": "Take pictures for certain activities.",
"NSMicrophoneUsageDescription": "Need microphone access for recording videos.",
"NSPhotoLibraryUsageDescription": "Select pictures for certain activities."
},
"supportsTablet": true
},
"jsEngine": "hermes",
"name": "react-native-demo",
"orientation": "portrait",
"slug": "react-native-demo",
"splash": {
"backgroundColor": "#ffffff",
"image": "./assets/splash.png",
"resizeMode": "contain"
},
"userInterfaceStyle": "light",
"version": "1.0.0",
"web": {
"favicon": "./assets/favicon.png"
}

View File

@@ -1,6 +1,6 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true
}
},
"extends": "expo/tsconfig.base"
}

View File

@@ -14,7 +14,7 @@
"@formbricks/js": "workspace:*",
"@formbricks/ui": "workspace:*",
"lucide-react": "0.452.0",
"next": "14.2.15",
"next": "14.2.16",
"react": "18.3.1",
"react-dom": "18.3.1"
},

View File

@@ -3,4 +3,4 @@ module.exports = {
tailwindcss: {},
autoprefixer: {},
},
}
};

View File

@@ -1,5 +1,5 @@
{
"exclude": ["node_modules"],
"extends": "@formbricks/config-typescript/nextjs.json",
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"]
}

View File

@@ -60,7 +60,7 @@ All you need to do is copy a `<script>` tag to your HTML head, and thats abou
</Property>
</Properties>
Refer to our [Example HTML project](https://github.com/formbricks/examples/tree/main/html) for more help! Now visit the [Validate your Setup](#validate-your-setup) section to verify your setup!
Now visit the [Validate your Setup](#validate-your-setup) section to verify your setup!
---
@@ -118,7 +118,7 @@ export default App;
</Property>
</Properties>
Refer to our [Example ReactJs project](https://github.com/formbricks/examples/tree/main/reactjs) for more help! Now visit the [Validate your Setup](#validate-your-setup) section to verify your setup!
Now visit the [Validate your Setup](#validate-your-setup) section to verify your setup!
---
@@ -200,8 +200,6 @@ export default function RootLayout({ children }: { children: React.ReactNode })
</CodeGroup>
</Col>
Refer to our [Example NextJS App Directory project](https://github.com/formbricks/examples/tree/main/nextjs-app) for more help!
### Pages Directory
<Col>
@@ -239,7 +237,6 @@ export default function App({ Component, pageProps }: AppProps) {
</CodeGroup>
</Col>
Refer to our [Example NextJS Pages Directory project](https://github.com/formbricks/examples/tree/main/nextjs-pages) for more help!
### Required customizations to be made
@@ -332,7 +329,7 @@ router.afterEach((to, from) => {
</Property>
</Properties>
Refer to our [Example VueJs project](https://github.com/formbricks/examples/tree/main/vuejs) for more help! Now visit the [Validate your Setup](#validate-your-setup) section to verify your setup!
Now visit the [Validate your Setup](#validate-your-setup) section to verify your setup!
## React Native

Binary file not shown.

Before

Width:  |  Height:  |  Size: 201 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -1,7 +1,7 @@
import { MdxImage } from "@/components/MdxImage";
import AddQuestion from "./images/add-question.webp";
import EmailField from "./images/email-field.webp";
import EmbedImage from "./images/embed.png";
import EmbedImage from "./images/embed.webp";
import MessageField from "./images/message-field.webp";
import NameField from "./images/name-field.webp";
import QueryImage from "./images/query-form.webp";
@@ -159,6 +159,8 @@ After publishing the form, follow these steps to integrate it into your site:
2. **Embed the Code**
- Copy the provided code and paste it into your website where you want the form to appear.
<Note>Note: There is an options toggle button called "Embed Mode." When enabled, it updates the `src` to `"?embed=true"` and displays your survey in a minimalist design, removing padding and background for a cleaner look.</Note>
3. **Test the Integration**
- Check if the form displays correctly on your site.
- Submit a test entry to ensure everything works and notifications are received.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 7.0 KiB

View File

@@ -39,13 +39,13 @@ To get this running, you'll need a bit of time. Here are the steps we're going t
1. To get started, create an account for the [Formbricks Cloud](https://app.formbricks.com/auth/signup).
2. In the Menu (top right) you see that you can switch between a “Development” and a “Production” environment. These are two separate environments so your test data doesnt mess up the insights from prod. Switch to “Development”:
2. In the Menu (top right) you see that you can toggle switch between a “Development” and a “Production” environment. These are two separate environments so your test data doesnt mess up the insights from prod. Switch to “Development”:
<MdxImage
src={SwitchToDev}
alt="switch to dev environment"
quality="100"
className="max-w-full rounded-lg sm:max-w-3xl"
className="max-w-full rounded-lg sm:max-w-xs"
/>
3. Then, create a survey using the template “Docs Feedback”:

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 462 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -115,7 +115,7 @@ Lastly, scroll down to “Recontact Options”. Here you have full freedom to de
src={RecontactOptions}
alt="Set recontact options"
quality="100"
className="max-w-full rounded-lg sm:max-w-3xl"
className="max-w-full rounded-lg sm:max-w-2xl"
/>
### 7. Congrats! Youre ready to publish your survey 💃

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

View File

@@ -0,0 +1,178 @@
import { MdxImage } from "@/components/MdxImage";
import SingleSelect from "./single-select.webp";
import Quiz from "./quiz.webp";
import Score from "./score.webp"
import AddLogic from "./conditional-logic.webp";
import WhenThen from "./when-then.webp";
import EndingLogic from "./ending-logic.webp";
import PassFail from "./pass-fail.webp";
export const metadata = {
title: "How to Create a Quiz Using Formbricks - Step-by-Step Guide",
description:
"Learn to leverage Formbricks to create Quizzes. Follow our detailed step-by-step guide to build quizzes with custom logic and multiple endings.",
};
# Creating a quiz with Formbricks - Step-by-step Guide
Welcome to this guide on creating engaging quizzes with Formbricks! Quizzes help you capture customer insights, explore user personalities, or simply add fun for your team. With Formbricks, you can personalize quizzes in minutes add scores, customize backgrounds, and more, all without any technical skills!
## What we'll build
By the end of this tutorial, you'll have created a simple trivia Quiz featuring:
1. Score calculations.
2. Multiple endings depending on the score.
## Setting up the form
First, make sure you have a Formbricks account. If not, you can create one [here](https://app.formbricks.com):
1. Head to the Surveys page and click on **New Survey**.
2. Select Start from Scratch to create a new form.
3. Go to the settings and select form type as **Link Survey**
4. In the form editor, click the three dots next to a question, then select Change Question Type and choose **Single-Select**.
<MdxImage
src={SingleSelect}
alt="Change Question type to Single-Select"
quality="100"
className="max-w-full rounded-lg sm:max-w-2xl"
/>
5. Add a welcoming statement to greet your users and explain the Quiz's purpose.
6. Personalize the greeting to make it inviting and encourage engagement.
**Note:** While were creating a Link Survey here, the process is similar for Web and App surveys.
## Adding the questions
Next, let's create a question for example with multiple options:
What country has the longest coastline in the world?
A) Canada
B) Japan
C) India
D) Nepal
<MdxImage
src={Quiz}
alt="Sample Question"
quality="100"
className="max-w-full rounded-lg sm:max-w-xl"
/>
## Calculate Score
Now that we have our question ready, lets add the logic to calculate scores.
1. Scroll down in the editor and click on variables.
2. Create a new variable named `score` with a default value of 0
<MdxImage
src={Score}
alt="Create Variable named Score image"
quality="100"
className="max-w-full rounded-lg sm:max-w-xl"
/>
3. Now go back to the question and expand the advanced settings by clicking on `> Show Advanced Settings`.
4. Under the conditional logic you should see the option to `Add Logic`. Click on that.
<MdxImage
src={AddLogic}
alt="Add Logic Button"
quality="100"
className="max-w-full rounded-lg sm:max-w-xl"
/>
5. Now you should see conditional logic. Customize the logic to match your needs for example:
**When** `question` equals `YOUR_ANSWER_HERE` **Then** `Calculate` `score` `Add +` `01`. So it should look something like this.
<MdxImage
src={WhenThen}
alt="When-Then Conditional Logic"
quality="100"
className="max-w-full rounded-lg sm:max-w-xl"
/>
6. Let's duplicate and customize these questions. Click on the duplicate icon at the top of the question.
7. Now edit the questions, options, and answers in the **conditional logic**
## Creating Multiple Endings Based on Scores
Once you have all the questions and the calculation logic in place, its time to customize the endings. Scroll down to the Ending Card section. We will create two cards for this quiz: one for when the user fails the quiz and another for when the user passes.
1. Customize the ending card.
2. Display the score by typing `@score`. ( You can address all the variables or questions by just typing @ ).
3. Add logic to the last question. ( this is necessary to redirect the user based on the score ). Kind of like this:
**When** `score` >= `03` **Then** `Jump to` `Pass`. So it should look something like this.
<MdxImage
src={EndingLogic}
alt="Conditional Logic for ending card"
quality="100"
className="max-w-full rounded-lg sm:max-w-xl"
/>
4. Ensure that the Fail card is positioned above the Pass card. This allows any condition that does not meet the criteria of being greater than or equal to 3 to jump to the Fail card.
<MdxImage
src={PassFail}
alt="Pass or Fail ending Cards"
quality="100"
className="max-w-full rounded-lg sm:max-w-xl"
/>
5. That's it! Now you can save and publish the quiz.
# Wrapping Up
Congratulations! Youve successfully created a Quiz with Formbricks. You can play around with the quiz that we just created [here](https://app.formbricks.com/s/cm2wwt3vu0001ir8o7ys0bezz).
A great quiz can serve as an excellent lead generator, a job fit checker, or just a fun icebreaker for your team. You now have the skills to build that! If you want to read more about building quizzes and how you can create a Job Fit Quiz check this article [here](https://www.harshbhat.me/blog/formbricks-quiz).

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

View File

@@ -8,7 +8,10 @@ import LinkWithQuestions from "./images/link-with-questions.webp";
import ListLinkedSurveys from "./images/list-linked-surveys.webp";
import SlackAuth from "./images/slack-auth.webp";
import SlackConnected from "./images/slack-connected.webp";
import AddSlackBot1 from "./images/add-slack-bot-1.webp";
import AddSlackBot2 from "./images/add-slack-bot-2.webp";
import AddSlackBot3 from "./images/add-slack-bot-3.webp";
import AddSlackBot4 from "./images/add-slack-bot-4.webp";
export const metadata = {
title: "Slack",
description:
@@ -69,7 +72,34 @@ The slack integration allows you to automatically send responses to a Slack chan
channel in the Slack workspace you integrated.
</Note>
5. Now click on the "Link channel" button to link a Slack channel with Formbricks and a modal will open up.
5. In order to make your channel available in channel dropdown, you need to add formbricks integration bot to the channel you want to link. You can do this by going to channel settings -> Integrations -> Add apps -> Search for "Formbricks" -> Select the bot -> Add.
<MdxImage
src={AddSlackBot1}
alt="Click on three dot at top right of the channel"
quality="100"
className="max-w-full rounded-lg sm:max-w-3xl"
/>
<MdxImage
src={AddSlackBot2}
alt="Select Edit Settings"
quality="100"
className="max-w-full rounded-lg sm:max-w-3xl"
/>
<MdxImage
src={AddSlackBot3}
alt="Navigate to Integrations"
quality="100"
className="max-w-full rounded-lg sm:max-w-3xl"
/>
<MdxImage
src={AddSlackBot4}
alt="Add Formbricks Bot"
quality="100"
className="max-w-full rounded-lg sm:max-w-3xl"
/>
6. Now click on the "Link channel" button to link a Slack channel with Formbricks and a modal will open up.
<MdxImage
src={LinkSurveyWithChannel}
@@ -78,7 +108,7 @@ The slack integration allows you to automatically send responses to a Slack chan
className="max-w-full rounded-lg sm:max-w-3xl"
/>
6. Select the channel you want to link with Formbricks and the Survey. On doing so, you will be asked to select the questions' responses you want to feed in the Slack channel. Select the questions and click on the "Link Channel" button.
7. Select the channel you want to link with Formbricks and the Survey. On doing so, you will be asked to select the questions' responses you want to feed in the Slack channel. Select the questions and click on the "Link Channel" button.
<MdxImage
src={LinkWithQuestions}
@@ -87,7 +117,7 @@ The slack integration allows you to automatically send responses to a Slack chan
className="max-w-full rounded-lg sm:max-w-3xl"
/>
7. On submitting, the modal will close and you will see the linked Slack channel in the list of linked Slack channels.
8. On submitting, the modal will close and you will see the linked Slack channel in the list of linked Slack channels.
<MdxImage
src={ListLinkedSurveys}
@@ -124,6 +154,7 @@ Enabling the Slack Integration in a self-hosted environment requires a setup usi
4. Go to the **OAuth & Permissions** tab on the sidebar and add the following **Bot Token Scopes**:
- `channels:read`
- `groups:read`
- `chat:write`
- `chat:write.public`
- `chat:write.customize`

View File

@@ -48,7 +48,7 @@ Initialize the Formbricks JS Client for surveys. When used in a web app, pass a
<CodeGroup title="Initialize Formbricks">
```javascript
import formbricks from "@formbricks/js/app";
import formbricks from "@formbricks/js";
formbricks.init({
environmentId: "<your-environment-id>", // required

View File

@@ -13,64 +13,91 @@ export const metadata = {
# Organization Access Roles
Assign different roles to organization members to grant them specific rights like creating surveys, viewing responses, or managing organization members.
Learn about the different organization-level and team-level roles and how they affect permissions in Formbricks.
## Memberships
Permissions in Formbricks are broadly handled using organization-level roles, which apply to all teams and projects in the organization. Users on a self-hosting and Enterprise plan, have access to team-level roles, which enable more granular permissions.
<Note>
Access Roles is a feature of the **Enterprise Edition**. In the **Community Edition** and on the **Free**
and **Startup** plan in the Cloud you can invite unlimited organization members as `Admins`.
and **Startup** plan in the Cloud you can invite unlimited organization members as `Owner`.
</Note>
Here are the different access permissions, ranked from highest to lowest access
1. Owner
2. Admin
3. Developer
4. Editor
5. Viewer
2. Manager
3. Billing
4. Member
### Organisational level
All users and their organization-level roles are listed in **Organization Settings > General**. Users can hold any of the following org-level roles:
- **Billing** users can manage payment and compliance details in the organization.
- **Org Members** can view most data in the organization and act in the products they are members of. They cannot join products on their own and need to be assigned.
- **Org Managers** have full management access to all teams and products. They can also manage the organization's membership. Org Managers can perform Team Admin actions without needing to join the team. They cannot change other organization settings.
- **Org Owners** have full access to the organization, its data, and settings. Org Owners can perform Team Admin actions without needing to join the team.
### Permissions at product level
- **read**: read access to all resources (except settings) in the product.
- **read & write**: read & write access to all resources (except settings) in the product.
- **manage**: read & write access to all resources including settings in the product.
### Team-level Roles
- **Team Contributors** can view and act on surveys and responses.
- **Team Admins** have additional permissions to manage their team's membership and products. These permissions are granted at the team-level, and don't apply to teams where they're not a Team Admin.
For more information on user roles & permissions, see below:
| | Owner | Admin | Editor | Developer | Viewer |
| -------------------------------- | ----- | ----- | ------ | --------- | ------ |
| **Organization** | | | | | |
| Update organization | ✅ | ✅ | ❌ | ❌ | ❌ |
| Delete organization | ✅ | ❌ | ❌ | ❌ | ❌ |
| Add new Member | ✅ | ✅ | ❌ | ❌ | ❌ |
| Delete Member | ✅ | ✅ | ❌ | ❌ | ❌ |
| Update Member Access | ✅ | ✅ | ❌ | ❌ | ❌ |
| Update Billing | ✅ | ✅ | ❌ | | ❌ |
| **Product** | | | | | |
| Create Product | ✅ | ✅ | ❌ | ❌ | ❌ |
| Update Product Name | ✅ | ✅ | ✅ | ❌ | ❌ |
| Update Product Recontact Options | ✅ | ✅ | ✅ | | |
| Update Look & Feel | ✅ | ✅ | ✅ | | |
| Update Survey Languages | ✅ | ✅ | ✅ | | |
| Delete Product | ✅ | ✅ | ✅ | | ❌ |
| **Surveys** | | | | | |
| Create New Survey | ✅ | ✅ | ✅ | | |
| Edit Survey | ✅ | ✅ | ✅ | | |
| Delete Survey | ✅ | ✅ | ✅ | | |
| View survey results | ✅ | ✅ | ✅ | | ✅ |
| **Response** | | | | | |
| Delete response | ✅ | ✅ | ✅ | | |
| Add tags on response | ✅ | ✅ | ✅ | | |
| Edit tags on response | ✅ | ✅ | ✅ | | |
| **Actions** | | | | | |
| Create Action | ✅ | ✅ | ✅ | | |
| Update Action | ✅ | ✅ | ✅ | | |
| Delete Action | ✅ | ✅ | ✅ | | |
| **API Keys** | | | | | |
| Create API key | ✅ | ✅ | ✅ | | |
| Update API key | ✅ | ✅ | ✅ | | |
| Delete API key | ✅ | ✅ | ✅ | | |
| **Tags** | | | | | |
| Create tags | ✅ | ✅ | ✅ | | |
| Update tags | ✅ | ✅ | ✅ | | |
| Delete tags | ✅ | ✅ | ✅ | | |
| **People** | | | | | |
| Delete Person | ✅ | ✅ | ✅ | | |
| **Integrations** | | | | | |
| Manage Integrations | ✅ | ✅ | ✅ | | |
| | Owner | Manager | Billing | Member |
| -------------------------------- | ----- | ------- | ------- | ------ |
| **Organization** | | | | |
| Update organization | ✅ | ❌ | ❌ | ❌ |
| Delete organization | ✅ | ❌ | ❌ | ❌ |
| Add new Member | ✅ | ✅ | ❌ | ❌ |
| Delete Member | ✅ | ✅ | ❌ | ❌ |
| Update Member Access | ✅ | ✅ | ❌ | ❌ |
| Update Billing | ✅ | ✅ | | ❌ |
| **Product** | | | | |
| Create Product | ✅ | ✅ | ❌ | ❌ |
| Update Product Name | ✅ | ✅ | ❌ | ✅\*\* |
| Update Product Recontact Options | ✅ | ✅ | | ✅\*\* |
| Update Look & Feel | ✅ | ✅ | | ✅\*\* |
| Update Survey Languages | ✅ | ✅ | | ✅\*\* |
| Delete Product | ✅ | ✅ | | ❌ |
| **Surveys** | | | | |
| Create New Survey | ✅ | ✅ | | ✅\* |
| Edit Survey | ✅ | ✅ | | ✅\* |
| Delete Survey | ✅ | ✅ | | ✅\* |
| View survey results | ✅ | ✅ | | ✅ |
| **Response** | | | | |
| Delete response | ✅ | ✅ | | ✅\* |
| Add tags on response | ✅ | ✅ | | ✅\* |
| Edit tags on response | ✅ | ✅ | | ✅\* |
| **Actions** | | | | |
| Create Action | ✅ | ✅ | | ✅\* |
| Update Action | ✅ | ✅ | | ✅\* |
| Delete Action | ✅ | ✅ | | ✅\* |
| **API Keys** | | | | |
| Create API key | ✅ | ✅ | | ✅\*\* |
| Update API key | ✅ | ✅ | | ✅\*\* |
| Delete API key | ✅ | ✅ | | ✅\*\* |
| **Tags** | | | | |
| Create tags | ✅ | ✅ | | ✅\* |
| Update tags | ✅ | ✅ | | ✅\* |
| Delete tags | ✅ | ✅ | | ✅\*\* |
| **People** | | | | |
| Delete Person | ✅ | ✅ | | ✅\* |
| **Integrations** | | | | |
| Manage Integrations | ✅ | ✅ | | ✅\* |
\* - for the read & write permissions team members
\*\* - for the manage permissions team members
## Inviting organization members

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

@@ -0,0 +1,45 @@
import { MdxImage } from "@/components/MdxImage";
import SurveyEmbed from "@/components/SurveyEmbed";
import AddImageOrVideoToQuestionImage from "./images/add-image-or-video-to-question-image.webp";
import AddImageOrVideoToQuestionVideo from "./images/add-image-or-video-to-question-video.webp";
import AddImageOrVideoToQuestion from "./images/add-image-or-video-to-question.webp";
#### Add Image or Video to a Question
Enhance your questions by adding images or videos. This makes instructions clearer and the survey more engaging.
## How to Add Images
Click the icon on the right side of the question to add an image or video:
<MdxImage
src={AddImageOrVideoToQuestion}
alt="Overview of adding image or video to question"
quality="100"
className="max-w-full rounded-lg sm:max-w-3xl"
/>
Upload an image by clicking the upload icon or dragging the file:
<MdxImage
src={AddImageOrVideoToQuestionImage}
alt="Overview of adding image to question"
quality="100"
className="max-w-full rounded-lg sm:max-w-3xl"
/>
## How to Add Videos
Toggle to add a video via link:
<MdxImage
src={AddImageOrVideoToQuestionVideo}
alt="Overview of adding video to question"
quality="100"
className="max-w-full rounded-lg sm:max-w-3xl"
/>
### Supported Video Platforms
We support YouTube, Vimeo, and Loom URLs.
<Note>**YouTube Privacy Mode**: This option reduces tracking by converting YouTube URLs to no-cookie URLs. It only works with YouTube.</Note>

View File

@@ -11,6 +11,7 @@ Matrix questions allow respondents to select a value for each option presented i
<SurveyEmbed surveyUrl="https://app.formbricks.com/s/obqeey0574jig4lo2gqyv51e" />
## Elements
<MdxImage
src={Matrix}
alt="Overview of Matrix question type"
@@ -19,18 +20,22 @@ Matrix questions allow respondents to select a value for each option presented i
/>
### Title
Add a clear title to inform the respondent what information you are asking for.
### Description
Provide an optional description with further instructions.
### Rows
Define the options shown on the left side of the matrix. These represent the items for which users will select a value.
### Columns
Represent the range of values from 0 to X (right side of the screen). Users can choose any value, including 0, using radio buttons.
### Select ordering
- Keep current order: This will keep the order of options the same for all respondents.
- Randomize all: This will randomize the options for each respondent.
- Randomize all: This will randomize the options for each respondent.

View File

@@ -63,7 +63,7 @@ These variables are present inside your machines docker-compose file. Restart
| TELEMETRY_DISABLED | Disables telemetry if set to 1. | optional | |
| DEFAULT_BRAND_COLOR | Default brand color for your app (Can be overwritten from the UI as well). | optional | #64748b |
| DEFAULT_ORGANIZATION_ID | Automatically assign new users to a specific organization when joining | optional | |
| DEFAULT_ORGANIZATION_ROLE | Role of the user in the default organization. | optional | admin |
| DEFAULT_ORGANIZATION_ROLE | Role of the user in the default organization. | optional | owner |
| OIDC_DISPLAY_NAME | Display name for Custom OpenID Connect Provider | optional | |
| OIDC_CLIENT_ID | Client ID for Custom OpenID Connect Provider | optional (required if OIDC auth is enabled) | |
| OIDC_CLIENT_SECRET | Secret for Custom OpenID Connect Provider | optional (required if OIDC auth is enabled) | |

View File

@@ -302,6 +302,7 @@ Enabling the Slack Integration in a self-hosted environment requires a setup usi
4. Go to the **OAuth & Permissions** tab on the sidebar and add the following **Bot Token Scopes**:
- `channels:read`
- `groups:read`
- `chat:write`
- `chat:write.public`
- `chat:write.customize`

View File

@@ -160,6 +160,33 @@ const NavigationGroup = ({
const pathname = usePathname();
const [isActiveGroup, setIsActiveGroup] = useState<boolean>(false);
// We need to expand the group with the current link so we loop over all links
// Until we find the one and then expand the groups
useEffect(() => {
const findMatchingGroup = () => {
for (const group of navigation) {
for (const link of group.links) {
if (!link.children) continue;
const matchingChild = link.children.find((child) => pathname && child.href.startsWith(pathname));
if (matchingChild) {
setOpenGroups([`${group.title}-${link.title}`]);
setActiveGroup(group);
return;
}
}
}
};
findMatchingGroup();
return () => {
setOpenGroups([]);
setActiveGroup(null);
};
}, [pathname, setActiveGroup, setOpenGroups]);
useEffect(() => {
setIsActiveGroup(activeGroup?.title === group.title);
}, [activeGroup?.title, group.title]);
@@ -262,6 +289,25 @@ interface NavigationProps extends React.ComponentPropsWithoutRef<"nav"> {
export const Navigation = ({ isMobile, ...props }: NavigationProps) => {
const [activeGroup, setActiveGroup] = useState<NavGroup | null>(navigation[0]);
const [openGroups, setOpenGroups] = useState<string[]>([]);
const pathname = usePathname();
useEffect(() => {
// Check the current pathname and set the active group
navigation.forEach((group) => {
group.links.forEach((link) => {
if (link.href && pathname.startsWith(link.href)) {
setActiveGroup(group);
} else if (link.children) {
link.children.forEach((child) => {
if (pathname.startsWith(child.href)) {
setActiveGroup(group);
setOpenGroups([`${group.title}-${link.title}`]); // Ensure parent is open
}
});
}
});
});
}, [pathname]);
return (
<nav {...props}>

View File

@@ -19,6 +19,7 @@ export const navigation: Array<NavGroup> = [
{ title: "Docs Feedback", href: "/best-practices/docs-feedback" },
{ title: "Improve Email Content", href: "/best-practices/improve-email-content" },
{ title: "Contact Form", href: "/best-practices/contact-form" },
{ title: "Quiz Time", href: "/best-practices/quiz-time" },
],
},
],
@@ -49,6 +50,10 @@ export const navigation: Array<NavGroup> = [
{ title: "Verify Email before Survey", href: "/link-surveys/verify-email-before-survey" },
{ title: "PIN Protected Surveys", href: "/link-surveys/pin-protected-surveys" },
{ title: "Partial Submissions", href: "/global/partial-submissions" },
{
title: "Add Image/Video to Question",
href: "/global/add-image-or-video-question",
},
],
},
],
@@ -76,6 +81,10 @@ export const navigation: Array<NavGroup> = [
{ title: "Recall Functionality", href: "/global/recall" }, // global
{ title: "Partial Submissions", href: "/global/partial-submissions" }, // global
{ title: "Shareable Dashboards", href: "/global/shareable-dashboards" },
{
title: "Add Image/Video to Question",
href: "/global/add-image-or-video-question",
},
],
},
],
@@ -117,7 +126,7 @@ export const navigation: Array<NavGroup> = [
{ title: "Zapier", href: "/developer-docs/integrations/zapier" },
],
},
{ title: "Access Roles", href: "/global/access-roles" },
{ title: "Organization and User Management", href: "/global/access-roles" },
{ title: "Styling Theme", href: "/global/styling-theme" },
],
},

View File

@@ -2,9 +2,19 @@ import { slugifyWithCounter } from "@sindresorhus/slugify";
import * as acorn from "acorn";
import { toString } from "mdast-util-to-string";
import { mdxAnnotations } from "mdx-annotations";
import { getHighlighter, renderToHtml } from "shiki";
import { createCssVariablesTheme, createHighlighter } from "shiki";
import { visit } from "unist-util-visit";
let highlighterPromise;
const supportedLanguages = ["javascript", "html", "shell", "tsx", "json", "yml", "ts"];
const myTheme = createCssVariablesTheme({
name: "css-variables",
variablePrefix: "--shiki-",
variableDefaults: {},
fontStyle: true,
});
const rehypeParseCodeBlocks = () => {
return (tree) => {
visit(tree, "element", (node, _nodeIndex, parentNode) => {
@@ -15,29 +25,31 @@ const rehypeParseCodeBlocks = () => {
};
};
let highlighter;
const getHighlighter = async () => {
if (!highlighterPromise) {
highlighterPromise = createHighlighter({
langs: supportedLanguages,
themes: [myTheme],
})
}
return highlighterPromise;
}
const rehypeShiki = () => {
return async (tree) => {
highlighter = highlighter ?? (await getHighlighter({ theme: "css-variables" }));
const highlighter = await getHighlighter();
visit(tree, "element", (node) => {
if (node.tagName === "pre" && node.children[0]?.tagName === "code") {
let codeNode = node.children[0];
let textNode = codeNode.children[0];
if (!codeNode || !textNode) return;
node.properties.code = textNode.value;
if (node.properties.language) {
let tokens = highlighter.codeToThemedTokens(textNode.value, node.properties.language);
textNode.value = renderToHtml(tokens, {
elements: {
pre: ({ children }) => children,
code: ({ children }) => children,
line: ({ children }) => `<span>${children}</span>`,
},
});
if (codeNode.properties.className && codeNode.properties.className.length > 0) {
let lang = codeNode.properties.className[0].replace("language-", "");
const code = highlighter.codeToHtml(textNode.value, { lang, theme: "css-variables" });
textNode.value = code;
}
}
});

View File

@@ -39,7 +39,7 @@
"lucide-react": "0.452.0",
"mdast-util-to-string": "4.0.0",
"mdx-annotations": "0.1.4",
"next": "14.2.15",
"next": "14.2.16",
"next-plausible": "3.12.2",
"next-seo": "6.6.0",
"next-sitemap": "4.2.3",
@@ -57,7 +57,7 @@
"remark-mdx": "3.0.1",
"schema-dts": "1.1.2",
"sharp": "0.33.5",
"shiki": "0.14.7",
"shiki": "1.22.0",
"simple-functional-loader": "1.2.1",
"tailwindcss": "3.4.13",
"unist-util-filter": "5.0.1",

View File

@@ -1,7 +1,4 @@
{
"extends": "@formbricks/config-typescript/nextjs.json",
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "../../packages/types/*.d.ts"],
"exclude": ["../../.env", "node_modules"],
"compilerOptions": {
"baseUrl": ".",
"paths": {
@@ -13,5 +10,8 @@
}
],
"strictNullChecks": true
}
},
"exclude": ["../../.env", "node_modules"],
"extends": "@formbricks/config-typescript/nextjs.json",
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "../../packages/types/*.d.ts"]
}

View File

@@ -1,35 +1,36 @@
import { Meta } from "@storybook/blocks";
import Github from "./assets/github.svg";
import Discord from "./assets/discord.svg";
import Youtube from "./assets/youtube.svg";
import Tutorials from "./assets/tutorials.svg";
import Styling from "./assets/styling.png";
import Context from "./assets/context.png";
import Assets from "./assets/assets.png";
import Docs from "./assets/docs.png";
import Share from "./assets/share.png";
import FigmaPlugin from "./assets/figma-plugin.png";
import Testing from "./assets/testing.png";
import Accessibility from "./assets/accessibility.png";
import Theming from "./assets/theming.png";
import AddonLibrary from "./assets/addon-library.png";
import Assets from "./assets/assets.png";
import Context from "./assets/context.png";
import Discord from "./assets/discord.svg";
import Docs from "./assets/docs.png";
import FigmaPlugin from "./assets/figma-plugin.png";
import Github from "./assets/github.svg";
import Share from "./assets/share.png";
import Styling from "./assets/styling.png";
import Testing from "./assets/testing.png";
import Theming from "./assets/theming.png";
import Tutorials from "./assets/tutorials.svg";
import Youtube from "./assets/youtube.svg";
export const RightArrow = () => <svg
viewBox="0 0 14 14"
width="8px"
height="14px"
style={{
marginLeft: '4px',
display: 'inline-block',
shapeRendering: 'inherit',
verticalAlign: 'middle',
fill: 'currentColor',
'path fill': 'currentColor'
}}
>
<path d="m11.1 7.35-5.5 5.5a.5.5 0 0 1-.7-.7L10.04 7 4.9 1.85a.5.5 0 1 1 .7-.7l5.5 5.5c.2.2.2.5 0 .7Z" />
</svg>
export const RightArrow = () => (
<svg
viewBox="0 0 14 14"
width="8px"
height="14px"
style={{
marginLeft: "4px",
display: "inline-block",
shapeRendering: "inherit",
verticalAlign: "middle",
fill: "currentColor",
"path fill": "currentColor",
}}>
<path d="m11.1 7.35-5.5 5.5a.5.5 0 0 1-.7-.7L10.04 7 4.9 1.85a.5.5 0 1 1 .7-.7l5.5 5.5c.2.2.2.5 0 .7Z" />
</svg>
);
<Meta title="Configure your project" />
@@ -38,6 +39,7 @@ export const RightArrow = () => <svg
# Configure your project
Because Storybook works separately from your app, you'll need to configure it for your specific stack and setup. Below, explore guides for configuring Storybook with popular frameworks and tools. If you get stuck, learn how you can ask for help from our community.
</div>
<div className="sb-section">
<div className="sb-section-item">
@@ -84,6 +86,7 @@ export const RightArrow = () => <svg
# Do more with Storybook
Now that you know the basics, let's explore other parts of Storybook that will improve your experience. This list is just to get you started. You can customise Storybook in many ways to fit your needs.
</div>
<div className="sb-section">
@@ -203,6 +206,7 @@ export const RightArrow = () => <svg
target="_blank"
>Discover tutorials<RightArrow /></a>
</div>
</div>
<style>

View File

@@ -1,24 +1,24 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"allowImportingTsExtensions": true,
"isolatedModules": true,
"jsx": "react-jsx",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"noFallthroughCasesInSwitch": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"resolveJsonModule": true,
"skipLibCheck": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
"target": "ES2020",
"useDefineForClassFields": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]

View File

@@ -1,10 +1,10 @@
{
"compilerOptions": {
"allowSyntheticDefaultImports": true,
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
"skipLibCheck": true
},
"include": ["vite.config.ts"]
}

View File

@@ -1,6 +1,7 @@
"use client";
import { ArrowRight } from "lucide-react";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { cn } from "@formbricks/lib/cn";
@@ -22,8 +23,8 @@ export const ConnectWithFormbricks = ({
widgetSetupCompleted,
channel,
}: ConnectWithFormbricksProps) => {
const t = useTranslations();
const router = useRouter();
const handleFinishOnboarding = async () => {
if (!widgetSetupCompleted) {
router.push(`/environments/${environment.id}/connect/invite`);
@@ -64,8 +65,10 @@ export const ConnectWithFormbricks = ({
)}>
{widgetSetupCompleted ? (
<div>
<p className="text-3xl">Congrats!</p>
<p className="pt-4 text-sm font-medium text-slate-600">Well done! We&apos;re connected.</p>
<p className="text-3xl">{t("environments.connect.congrats")}</p>
<p className="pt-4 text-sm font-medium text-slate-600">
{t("environments.connect.connection_successful_message")}
</p>
</div>
) : (
<div className="flex animate-pulse flex-col items-center space-y-4">
@@ -73,7 +76,9 @@ export const ConnectWithFormbricks = ({
<span className="animate-ping-slow absolute inline-flex h-full w-full rounded-full bg-slate-400 opacity-75"></span>
<span className="relative inline-flex h-10 w-10 rounded-full bg-slate-500"></span>
</span>
<p className="pt-4 text-sm font-medium text-slate-600">Waiting for your signal...</p>
<p className="pt-4 text-sm font-medium text-slate-600">
{t("environments.connect.waiting_for_your_signal")}
</p>
</div>
)}
</div>
@@ -83,7 +88,9 @@ export const ConnectWithFormbricks = ({
variant={widgetSetupCompleted ? "primary" : "minimal"}
onClick={handleFinishOnboarding}
EndIcon={ArrowRight}>
{widgetSetupCompleted ? "Finish Onboarding" : "I don't know how to do it"}
{widgetSetupCompleted
? t("environments.connect.finish_onboarding")
: t("environments.connect.i_dont_know_how_to_do_it")}
</Button>
</div>
);

View File

@@ -2,6 +2,7 @@
import { inviteOrganizationMemberAction } from "@/app/(app)/(onboarding)/organizations/actions";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { FormProvider, useForm } from "react-hook-form";
import { toast } from "react-hot-toast";
@@ -24,11 +25,11 @@ type TInviteOrganizationMemberDetails = z.infer<typeof ZInviteOrganizationMember
export const InviteOrganizationMember = ({ organization, environmentId }: InviteOrganizationMemberProps) => {
const router = useRouter();
const t = useTranslations();
const form = useForm<TInviteOrganizationMemberDetails>({
defaultValues: {
email: "",
inviteMessage: "I'm looking into Formbricks to run targeted surveys. Can you help me set it up? 🙏",
inviteMessage: t("environments.connect.invite.invite_message_content"),
},
resolver: zodResolver(ZInviteOrganizationMemberDetails),
});
@@ -39,7 +40,7 @@ export const InviteOrganizationMember = ({ organization, environmentId }: Invite
await inviteOrganizationMemberAction({
organizationId: organization.id,
email: data.email,
role: "developer",
role: "member",
inviteMessage: data.inviteMessage,
});
toast.success("Invite sent successful");
@@ -63,7 +64,7 @@ export const InviteOrganizationMember = ({ organization, environmentId }: Invite
name="email"
render={({ field, fieldState: { error } }) => (
<FormItem className="w-full space-y-4">
<FormLabel>Email</FormLabel>
<FormLabel>{t("common.email")}</FormLabel>
<FormControl>
<div>
<Input
@@ -83,7 +84,7 @@ export const InviteOrganizationMember = ({ organization, environmentId }: Invite
name="inviteMessage"
render={({ field, fieldState: { error } }) => (
<FormItem className="w-full space-y-4">
<FormLabel>Invite Message</FormLabel>
<FormLabel>{t("environments.connect.invite.invite_message")}</FormLabel>
<FormControl>
<div>
<textarea
@@ -108,10 +109,10 @@ export const InviteOrganizationMember = ({ organization, environmentId }: Invite
e.preventDefault();
finishOnboarding();
}}>
Not now
{t("common.not_now")}
</Button>
<Button id="onboarding-inapp-invite-send-invite" type={"submit"} loading={isSubmitting}>
Invite
{t("common.invite")}
</Button>
</div>
</div>

View File

@@ -1,5 +1,6 @@
"use client";
import { useTranslations } from "next-intl";
import "prismjs/themes/prism.css";
import { useState } from "react";
import toast from "react-hot-toast";
@@ -27,6 +28,7 @@ export const OnboardingSetupInstructions = ({
channel,
widgetSetupCompleted,
}: OnboardingSetupInstructionsProps) => {
const t = useTranslations();
const [activeTab, setActiveTab] = useState(tabs[0].id);
const htmlSnippetForAppSurveys = `<!-- START Formbricks Surveys -->
<script type="text/javascript">
@@ -103,12 +105,12 @@ export const OnboardingSetupInstructions = ({
<CodeBlock customEditorClass="!bg-white border border-slate-200" language="sh">
npm install @formbricks/js
</CodeBlock>
<p>or</p>
<p>{t("common.or")}</p>
<CodeBlock customEditorClass="!bg-white border border-slate-200" language="sh">
yarn add @formbricks/js
</CodeBlock>
<p className="text-sm text-slate-700">
Import Formbricks and initialize the widget in your Component (e.g. App.tsx):
{t("environments.connect.import_formbricks_and_initialize_the_widget_in_your_component")}
</p>
<CodeBlock customEditorClass="!bg-white border border-slate-200" language="js">
{channel === "app" ? npmSnippetForAppSurveys : npmSnippetForWebsiteSurveys}
@@ -119,13 +121,13 @@ export const OnboardingSetupInstructions = ({
variant="secondary"
href={`https://formbricks.com/docs/${channel}-surveys/framework-guides`}
target="_blank">
Read docs
{t("common.read_docs")}
</Button>
</div>
) : activeTab === "html" ? (
<div className="prose prose-slate">
<p className="-mb-1 mt-6 text-sm text-slate-700">
Insert this code into the &lt;head&gt; tag of your website:
{t("environments.connect.insert_this_code_into_the_head_tag_of_your_website")}
</p>
<div>
<CodeBlock customEditorClass="!bg-white border border-slate-200" language="js">
@@ -141,16 +143,16 @@ export const OnboardingSetupInstructions = ({
navigator.clipboard.writeText(
channel === "app" ? htmlSnippetForAppSurveys : htmlSnippetForWebsiteSurveys
);
toast.success("Copied to clipboard");
toast.success(t("common.copied_to_clipboard"));
}}>
Copy code
{t("common.copy_code")}
</Button>
<Button
id="onboarding-inapp-connect-step-by-step-manual"
variant="secondary"
href={`https://formbricks.com/docs/${channel}-surveys/framework-guides#html`}
target="_blank">
Step by step manual
{t("common.step_by_step_manual")}
</Button>
</div>
</div>

View File

@@ -1,6 +1,7 @@
import { InviteOrganizationMember } from "@/app/(app)/(onboarding)/environments/[environmentId]/connect/components/InviteOrganizationMember";
import { XIcon } from "lucide-react";
import { getServerSession } from "next-auth";
import { getTranslations } from "next-intl/server";
import { notFound, redirect } from "next/navigation";
import { authOptions } from "@formbricks/lib/authOptions";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
@@ -15,6 +16,7 @@ interface InvitePageProps {
}
const Page = async ({ params }: InvitePageProps) => {
const t = await getTranslations();
const session = await getServerSession(authOptions);
if (!session || !session.user) {
return redirect(`/auth/login`);
@@ -25,15 +27,15 @@ const Page = async ({ params }: InvitePageProps) => {
}
const membership = await getMembershipByUserIdOrganizationId(session.user.id, organization.id);
if (!membership || (membership.role !== "owner" && membership.role !== "admin")) {
if (!membership || (membership.role !== "owner" && membership.role !== "manager")) {
return notFound();
}
return (
<div className="flex min-h-full min-w-full flex-col items-center justify-center">
<Header
title="Who is your favorite engineer?"
subtitle="Invite your tech-savvy co-worker to help with the setup."
title={t("environments.connect.invite.headline")}
subtitle={t("environments.connect.invite.subtitle")}
/>
<div className="space-y-4 text-center">
<p className="text-4xl font-medium text-slate-800"></p>

View File

@@ -1,5 +1,6 @@
import { ConnectWithFormbricks } from "@/app/(app)/(onboarding)/environments/[environmentId]/connect/components/ConnectWithFormbricks";
import { XIcon } from "lucide-react";
import { getTranslations } from "next-intl/server";
import { WEBAPP_URL } from "@formbricks/lib/constants";
import { getEnvironment } from "@formbricks/lib/environment/service";
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
@@ -13,22 +14,23 @@ interface ConnectPageProps {
}
const Page = async ({ params }: ConnectPageProps) => {
const t = await getTranslations();
const environment = await getEnvironment(params.environmentId);
if (!environment) {
throw new Error("Environment not found");
throw new Error(t("common.environment_not_found"));
}
const product = await getProductByEnvironmentId(environment.id);
if (!product) {
throw new Error("Product not found");
throw new Error(t("common.product_not_found"));
}
const channel = product.config.channel || null;
return (
<div className="flex min-h-full flex-col items-center justify-center py-10">
<Header title={`Let's connect your product with Formbricks`} subtitle="It takes less than 4 minutes." />
<Header title={t("environments.connect.headline")} subtitle={t("environments.connect.subtitle")} />
<div className="space-y-4 text-center">
<p className="text-4xl font-medium text-slate-800"></p>
<p className="text-sm text-slate-500"></p>

View File

@@ -1,18 +1,19 @@
"use client";
import { replacePresetPlaceholders } from "@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/lib/utils";
import { XMTemplates } from "@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/lib/xm-templates";
import { getXMTemplates } from "@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/lib/xm-templates";
import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer";
import { ActivityIcon, ShoppingCartIcon, SmileIcon, StarIcon, ThumbsUpIcon, UsersIcon } from "lucide-react";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { createSurveyAction } from "@/modules/surveys/components/TemplateList/actions";
import { ActivityIcon, ShoppingCartIcon, UsersIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { useState } from "react";
import toast from "react-hot-toast";
import { getFormattedErrorMessage } from "@formbricks/lib/actionClient/helper";
import { TProduct } from "@formbricks/types/product";
import { TSurveyCreateInput } from "@formbricks/types/surveys/types";
import { TXMTemplate } from "@formbricks/types/templates";
import { TUser } from "@formbricks/types/user";
import { createSurveyAction } from "@formbricks/ui/components/TemplateList/actions";
interface XMTemplateListProps {
product: TProduct;
@@ -22,7 +23,7 @@ interface XMTemplateListProps {
export const XMTemplateList = ({ product, user, environmentId }: XMTemplateListProps) => {
const [activeTemplateId, setActiveTemplateId] = useState<number | null>(null);
const t = useTranslations();
const router = useRouter();
const createSurvey = async (activeTemplate: TXMTemplate) => {
@@ -46,50 +47,50 @@ export const XMTemplateList = ({ product, user, environmentId }: XMTemplateListP
const handleTemplateClick = (templateIdx) => {
setActiveTemplateId(templateIdx);
const template = XMTemplates[templateIdx];
const template = getXMTemplates(user.locale)[templateIdx];
const newTemplate = replacePresetPlaceholders(template, product);
createSurvey(newTemplate);
};
const XMTemplateOptions = [
{
title: "NPS",
description: "Implement proven best practices to understand WHY people buy.",
title: t("environments.xm-templates.nps"),
description: t("environments.xm-templates.nps_description"),
icon: ShoppingCartIcon,
onClick: () => handleTemplateClick(0),
isLoading: activeTemplateId === 0,
},
{
title: "5-Star Rating",
description: "Universal feedback solution to gauge overall satisfaction.",
/* {
title: t("environments.xm-templates.five_star_rating"),
description: t("environments.xm-templates.five_star_rating_description"),
icon: StarIcon,
onClick: () => handleTemplateClick(1),
isLoading: activeTemplateId === 1,
},
{
title: "CSAT",
description: "Implement best practices to measure customer satisfaction.",
title: t("environments.xm-templates.csat"),
description: t("environments.xm-templates.csat_description"),
icon: ThumbsUpIcon,
onClick: () => handleTemplateClick(2),
isLoading: activeTemplateId === 2,
},
}, */
{
title: "CES",
description: "Leverage every touchpoint to understand ease of customer interaction.",
title: t("environments.xm-templates.ces"),
description: t("environments.xm-templates.ces_description"),
icon: ActivityIcon,
onClick: () => handleTemplateClick(3),
isLoading: activeTemplateId === 3,
},
{
title: "Smileys",
description: "Use visual indicators to capture feedback across customer touchpoints.",
/* {
title: t("environments.xm-templates.smileys"),
description: t("environments.xm-templates.smileys_description"),
icon: SmileIcon,
onClick: () => handleTemplateClick(4),
isLoading: activeTemplateId === 4,
},
}, */
{
title: "eNPS",
description: "Universal feedback to understand employee engagement and satisfaction.",
title: t("environments.xm-templates.enps"),
description: t("environments.xm-templates.enps_description"),
icon: UsersIcon,
onClick: () => handleTemplateClick(5),
isLoading: activeTemplateId === 5,

View File

@@ -1,42 +1,61 @@
import { createId } from "@paralleldrive/cuid2";
import { getDefaultEndingCard } from "@formbricks/lib/templates";
import { getDefaultEndingCard, translate } from "@formbricks/lib/templates";
import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { TXMTemplate } from "@formbricks/types/templates";
export const XMSurveyDefault: TXMTemplate = {
name: "",
endings: [getDefaultEndingCard([])],
questions: [],
styling: {
overwriteThemeStyling: true,
},
function validateLocale(locale: string): boolean {
// Add logic to validate the locale, e.g., check against a list of supported locales
return typeof locale === "string" && locale.length > 0;
}
function logError(error: Error, context: string) {
console.error(`Error in ${context}:`, error);
}
export const getXMSurveyDefault = (locale: string): TXMTemplate => {
try {
if (!validateLocale(locale)) {
throw new Error("Invalid locale");
}
return {
name: "",
endings: [getDefaultEndingCard([], locale)],
questions: [],
styling: {
overwriteThemeStyling: true,
},
};
} catch (error) {
logError(error, "getXMSurveyDefault");
throw error; // Re-throw after logging
}
};
const NPSSurvey = (): TXMTemplate => {
const NPSSurvey = (locale: string): TXMTemplate => {
return {
...XMSurveyDefault,
name: "NPS Survey",
...getXMSurveyDefault(locale),
name: translate("nps_survey_name", locale),
questions: [
{
id: createId(),
type: TSurveyQuestionTypeEnum.NPS,
headline: { default: "How likely are you to recommend {{productName}} to a friend or colleague?" },
headline: { default: translate("nps_survey_question_1_headline", locale) },
required: true,
lowerLabel: { default: "Not at all likely" },
upperLabel: { default: "Extremely likely" },
lowerLabel: { default: translate("nps_survey_question_1_lower_label", locale) },
upperLabel: { default: translate("nps_survey_question_1_upper_label", locale) },
isColorCodingEnabled: true,
},
{
id: createId(),
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "To help us improve, can you describe the reason(s) for your rating?" },
headline: { default: translate("nps_survey_question_2_headline", locale) },
required: false,
inputType: "text",
},
{
id: createId(),
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Any other comments, feedback, or concerns?" },
headline: { default: translate("nps_survey_question_3_headline", locale) },
required: false,
inputType: "text",
},
@@ -44,12 +63,12 @@ const NPSSurvey = (): TXMTemplate => {
};
};
const StarRatingSurvey = (): TXMTemplate => {
const StarRatingSurvey = (locale: string): TXMTemplate => {
const reusableQuestionIds = [createId(), createId(), createId()];
return {
...XMSurveyDefault,
name: "{{productName}}'s Rating Survey",
...getXMSurveyDefault(locale),
name: translate("star_rating_survey_name", locale),
questions: [
{
id: reusableQuestionIds[0],
@@ -86,15 +105,15 @@ const StarRatingSurvey = (): TXMTemplate => {
],
range: 5,
scale: "number",
headline: { default: "How do you like {{productName}}?" },
headline: { default: translate("star_rating_survey_question_1_headline", locale) },
required: true,
lowerLabel: { default: "Extremely dissatisfied" },
upperLabel: { default: "Extremely satisfied" },
lowerLabel: { default: translate("star_rating_survey_question_1_lower_label", locale) },
upperLabel: { default: translate("star_rating_survey_question_1_upper_label", locale) },
isColorCodingEnabled: false,
},
{
id: reusableQuestionIds[1],
html: { default: '<p class="fb-editor-paragraph" dir="ltr"><span>This helps us a lot.</span></p>' },
html: { default: translate("star_rating_survey_question_2_html", locale) },
type: TSurveyQuestionTypeEnum.CTA,
logic: [
{
@@ -117,15 +136,15 @@ const StarRatingSurvey = (): TXMTemplate => {
{
id: createId(),
objective: "jumpToQuestion",
target: XMSurveyDefault.endings[0].id,
target: getXMSurveyDefault(locale).endings[0].id,
},
],
},
],
headline: { default: "Happy to hear 🙏 Please write a review for us!" },
headline: { default: translate("star_rating_survey_question_2_headline", locale) },
required: true,
buttonUrl: "https://formbricks.com/github",
buttonLabel: { default: "Write review" },
buttonLabel: { default: translate("star_rating_survey_question_2_button_label", locale) },
buttonExternal: true,
},
{
@@ -142,12 +161,12 @@ const StarRatingSurvey = (): TXMTemplate => {
};
};
const CSATSurvey = (): TXMTemplate => {
const CSATSurvey = (locale: string): TXMTemplate => {
const reusableQuestionIds = [createId(), createId(), createId()];
return {
...XMSurveyDefault,
name: "{{productName}} CSAT",
...getXMSurveyDefault(locale),
name: translate("csat_survey_name", locale),
questions: [
{
id: reusableQuestionIds[0],
@@ -184,10 +203,10 @@ const CSATSurvey = (): TXMTemplate => {
],
range: 5,
scale: "smiley",
headline: { default: "How satisfied are you with your {{productName}} experience?" },
headline: { default: translate("csat_survey_question_1_headline", locale) },
required: true,
lowerLabel: { default: "Extremely dissatisfied" },
upperLabel: { default: "Extremely satisfied" },
lowerLabel: { default: translate("csat_survey_question_1_lower_label", locale) },
upperLabel: { default: translate("csat_survey_question_1_upper_label", locale) },
isColorCodingEnabled: false,
},
{
@@ -214,62 +233,62 @@ const CSATSurvey = (): TXMTemplate => {
{
id: createId(),
objective: "jumpToQuestion",
target: XMSurveyDefault.endings[0].id,
target: getXMSurveyDefault(locale).endings[0].id,
},
],
},
],
headline: { default: "Lovely! Is there anything we can do to improve your experience?" },
headline: { default: translate("csat_survey_question_2_headline", locale) },
required: false,
placeholder: { default: "Type your answer here..." },
placeholder: { default: translate("csat_survey_question_2_placeholder", locale) },
inputType: "text",
},
{
id: reusableQuestionIds[2],
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Ugh, sorry! Is there anything we can do to improve your experience?" },
headline: { default: translate("csat_survey_question_3_headline", locale) },
required: false,
placeholder: { default: "Type your answer here..." },
placeholder: { default: translate("csat_survey_question_3_placeholder", locale) },
inputType: "text",
},
],
};
};
const CESSurvey = (): TXMTemplate => {
const CESSurvey = (locale: string): TXMTemplate => {
return {
...XMSurveyDefault,
name: "CES Survey",
...getXMSurveyDefault(locale),
name: translate("cess_survey_name", locale),
questions: [
{
id: createId(),
type: TSurveyQuestionTypeEnum.Rating,
range: 5,
scale: "number",
headline: { default: "{{productName}} makes it easy for me to [ADD GOAL]" },
headline: { default: translate("cess_survey_question_1_headline", locale) },
required: true,
lowerLabel: { default: "Disagree strongly" },
upperLabel: { default: "Agree strongly" },
lowerLabel: { default: translate("cess_survey_question_1_lower_label", locale) },
upperLabel: { default: translate("cess_survey_question_1_upper_label", locale) },
isColorCodingEnabled: false,
},
{
id: createId(),
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Thanks! How could we make it easier for you to [ADD GOAL]?" },
headline: { default: translate("cess_survey_question_2_headline", locale) },
required: true,
placeholder: { default: "Type your answer here..." },
placeholder: { default: translate("cess_survey_question_2_placeholder", locale) },
inputType: "text",
},
],
};
};
const SmileysRatingSurvey = (): TXMTemplate => {
const SmileysRatingSurvey = (locale: string): TXMTemplate => {
const reusableQuestionIds = [createId(), createId(), createId()];
return {
...XMSurveyDefault,
name: "Smileys Survey",
...getXMSurveyDefault(locale),
name: translate("smileys_survey_name", locale),
questions: [
{
id: reusableQuestionIds[0],
@@ -306,15 +325,15 @@ const SmileysRatingSurvey = (): TXMTemplate => {
],
range: 5,
scale: "smiley",
headline: { default: "How do you like {{productName}}?" },
headline: { default: translate("smileys_survey_question_1_headline", locale) },
required: true,
lowerLabel: { default: "Not good" },
upperLabel: { default: "Very satisfied" },
lowerLabel: { default: translate("smileys_survey_question_1_lower_label", locale) },
upperLabel: { default: translate("smileys_survey_question_1_upper_label", locale) },
isColorCodingEnabled: false,
},
{
id: reusableQuestionIds[1],
html: { default: '<p class="fb-editor-paragraph" dir="ltr"><span>This helps us a lot.</span></p>' },
html: { default: translate("smileys_survey_question_2_html", locale) },
type: TSurveyQuestionTypeEnum.CTA,
logic: [
{
@@ -337,58 +356,58 @@ const SmileysRatingSurvey = (): TXMTemplate => {
{
id: createId(),
objective: "jumpToQuestion",
target: XMSurveyDefault.endings[0].id,
target: getXMSurveyDefault(locale).endings[0].id,
},
],
},
],
headline: { default: "Happy to hear 🙏 Please write a review for us!" },
headline: { default: translate("smileys_survey_question_2_headline", locale) },
required: true,
buttonUrl: "https://formbricks.com/github",
buttonLabel: { default: "Write review" },
buttonLabel: { default: translate("smileys_survey_question_2_button_label", locale) },
buttonExternal: true,
},
{
id: reusableQuestionIds[2],
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Sorry to hear! What is ONE thing we can do better?" },
headline: { default: translate("smileys_survey_question_3_headline", locale) },
required: true,
subheader: { default: "Help us improve your experience." },
buttonLabel: { default: "Send" },
placeholder: { default: "Type your answer here..." },
subheader: { default: translate("smileys_survey_question_3_subheader", locale) },
buttonLabel: { default: translate("smileys_survey_question_3_button_label", locale) },
placeholder: { default: translate("smileys_survey_question_3_placeholder", locale) },
inputType: "text",
},
],
};
};
const eNPSSurvey = (): TXMTemplate => {
const eNPSSurvey = (locale: string): TXMTemplate => {
return {
...XMSurveyDefault,
name: "eNPS Survey",
...getXMSurveyDefault(locale),
name: translate("enps_survey_name", locale),
questions: [
{
id: createId(),
type: TSurveyQuestionTypeEnum.NPS,
headline: {
default: "How likely are you to recommend working at this company to a friend or colleague?",
default: translate("enps_survey_question_1_headline", locale),
},
required: false,
lowerLabel: { default: "Not at all likely" },
upperLabel: { default: "Extremely likely" },
lowerLabel: { default: translate("enps_survey_question_1_lower_label", locale) },
upperLabel: { default: translate("enps_survey_question_1_upper_label", locale) },
isColorCodingEnabled: true,
},
{
id: createId(),
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "To help us improve, can you describe the reason(s) for your rating?" },
headline: { default: translate("enps_survey_question_2_headline", locale) },
required: false,
inputType: "text",
},
{
id: createId(),
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Any other comments, feedback, or concerns?" },
headline: { default: translate("enps_survey_question_3_headline", locale) },
required: false,
inputType: "text",
},
@@ -396,11 +415,21 @@ const eNPSSurvey = (): TXMTemplate => {
};
};
export const XMTemplates: TXMTemplate[] = [
NPSSurvey(),
StarRatingSurvey(),
CSATSurvey(),
CESSurvey(),
SmileysRatingSurvey(),
eNPSSurvey(),
];
export const getXMTemplates = (locale: string): TXMTemplate[] => {
try {
if (!validateLocale(locale)) {
throw new Error("Invalid locale");
}
return [
NPSSurvey(locale),
StarRatingSurvey(locale),
CSATSurvey(locale),
CESSurvey(locale),
SmileysRatingSurvey(locale),
eNPSSurvey(locale),
];
} catch (error) {
logError(error, "getXMTemplates");
return []; // Return an empty array or handle as needed
}
};

View File

@@ -1,10 +1,11 @@
import { XMTemplateList } from "@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/components/XMTemplateList";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
import { XIcon } from "lucide-react";
import { getServerSession } from "next-auth";
import { getTranslations } from "next-intl/server";
import { authOptions } from "@formbricks/lib/authOptions";
import { getEnvironment } from "@formbricks/lib/environment/service";
import { getOrganizationIdFromEnvironmentId } from "@formbricks/lib/organization/utils";
import { getProductByEnvironmentId, getProducts } from "@formbricks/lib/product/service";
import { getProductByEnvironmentId, getUserProducts } from "@formbricks/lib/product/service";
import { getUser } from "@formbricks/lib/user/service";
import { Button } from "@formbricks/ui/components/Button";
import { Header } from "@formbricks/ui/components/Header";
@@ -18,32 +19,31 @@ interface XMTemplatePageProps {
const Page = async ({ params }: XMTemplatePageProps) => {
const session = await getServerSession(authOptions);
const environment = await getEnvironment(params.environmentId);
const t = await getTranslations();
if (!session) {
throw new Error("Session not found");
throw new Error(t("common.session_not_found"));
}
const user = await getUser(session.user.id);
if (!user) {
throw new Error("User not found");
throw new Error(t("common.user_not_found"));
}
if (!environment) {
throw new Error("Environment not found");
throw new Error(t("common.environment_not_found"));
}
const organizationId = await getOrganizationIdFromEnvironmentId(environment.id);
const product = await getProductByEnvironmentId(environment.id);
if (!product) {
throw new Error("Product not found");
throw new Error(t("common.product_not_found"));
}
const products = await getProducts(organizationId);
const products = await getUserProducts(session.user.id, organizationId);
return (
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
<Header title="What kind of feedback would you like to get?" />
<Header title={t("environments.xm-templates.headline")} />
<XMTemplateList product={product} user={user} environmentId={environment.id} />
{products.length >= 2 && (
<Button

View File

@@ -0,0 +1,48 @@
"use server";
import { TOrganizationTeam } from "@/app/(app)/(onboarding)/types/onboarding";
import { teamCache } from "@/lib/cache/team";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { cache } from "@formbricks/lib/cache";
import { validateInputs } from "@formbricks/lib/utils/validate";
import { ZId } from "@formbricks/types/common";
import { DatabaseError } from "@formbricks/types/errors";
export const getTeamsByOrganizationId = reactCache(
(organizationId: string): Promise<TOrganizationTeam[] | null> =>
cache(
async () => {
validateInputs([organizationId, ZId]);
try {
const teams = await prisma.team.findMany({
where: {
organizationId,
},
select: {
id: true,
name: true,
},
});
const productTeams = teams.map((team) => ({
id: team.id,
name: team.name,
}));
return productTeams;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
},
[`getTeamsByOrganizationId-${organizationId}`],
{
tags: [teamCache.tag.byOrganizationId(organizationId)],
}
)()
);

View File

@@ -3,10 +3,10 @@ import { TProductConfigChannel } from "@formbricks/types/product";
export const getCustomHeadline = (channel?: TProductConfigChannel) => {
switch (channel) {
case "website":
return "Let's get the most out of your website traffic!";
return "organizations.products.new.settings.website_channel_headline";
case "app":
return "Let's research what your users need!";
return "organizations.products.new.settings.app_channel_headline";
default:
return "You maintain a product, how exciting!";
return "organizations.products.new.settings.link_channel_headline";
}
};

View File

@@ -0,0 +1,185 @@
"use client";
import { formbricksLogout } from "@/app/lib/formbricks";
import FBLogo from "@/images/formbricks-wordmark.svg";
import { CreateOrganizationModal } from "@/modules/organization/components/CreateOrganizationModal";
import { ArrowUpRightIcon, ChevronRightIcon, LogOutIcon, PlusIcon } from "lucide-react";
import { signOut } from "next-auth/react";
import { useTranslations } from "next-intl";
import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useMemo, useState } from "react";
import { AiOutlineDiscord } from "react-icons/ai";
import { cn } from "@formbricks/lib/cn";
import { capitalizeFirstLetter } from "@formbricks/lib/utils/strings";
import { TOrganization } from "@formbricks/types/organizations";
import { TUser } from "@formbricks/types/user";
import { ProfileAvatar } from "@formbricks/ui/components/Avatars";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuPortal,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@formbricks/ui/components/DropdownMenu";
interface LandingSidebarProps {
isMultiOrgEnabled: boolean;
user: TUser;
organization: TOrganization;
organizations: TOrganization[];
}
export const LandingSidebar = ({
isMultiOrgEnabled,
user,
organization,
organizations,
}: LandingSidebarProps) => {
const [openCreateOrganizationModal, setOpenCreateOrganizationModal] = useState<boolean>(false);
const t = useTranslations();
const router = useRouter();
const handleEnvironmentChangeByOrganization = (organizationId: string) => {
router.push(`/organizations/${organizationId}/`);
};
const dropdownNavigation = [
{
label: t("common.documentation"),
href: "https://formbricks.com/docs",
target: "_blank",
icon: ArrowUpRightIcon,
},
{
label: t("common.join_discord"),
href: "https://formbricks.com/discord",
target: "_blank",
icon: AiOutlineDiscord,
},
];
const currentOrganizationId = organization?.id;
const currentOrganizationName = capitalizeFirstLetter(organization?.name);
const sortedOrganizations = useMemo(() => {
return [...organizations].sort((a, b) => a.name.localeCompare(b.name));
}, [organizations]);
return (
<aside
className={cn(
"w-sidebar-collapsed z-40 flex flex-col justify-between rounded-r-xl border-r border-slate-200 bg-white pt-3 shadow-md transition-all duration-100"
)}>
<Image src={FBLogo} width={160} height={30} alt={t("environments.formbricks_logo")} />
<div className="flex items-center">
<DropdownMenu>
<DropdownMenuTrigger
asChild
id="userDropdownTrigger"
className="w-full rounded-br-xl border-t py-4 pl-4 transition-colors duration-200 hover:bg-slate-50 focus:outline-none">
<div tabIndex={0} className={cn("flex cursor-pointer flex-row items-center space-x-3")}>
<ProfileAvatar userId={user.id} imageUrl={user.imageUrl} />
<>
<div>
<p
title={user?.email}
className={cn(
"ph-no-capture ph-no-capture -mb-0.5 max-w-28 truncate text-sm font-bold text-slate-700"
)}>
{user?.name ? <span>{user?.name}</span> : <span>{user?.email}</span>}
</p>
<p
title={capitalizeFirstLetter(organization?.name)}
className="max-w-28 truncate text-sm text-slate-500">
{capitalizeFirstLetter(organization?.name)}
</p>
</div>
<ChevronRightIcon className={cn("h-5 w-5 text-slate-700 hover:text-slate-500")} />
</>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent
id="userDropdownInnerContentWrapper"
side="right"
sideOffset={10}
alignOffset={5}
align="end">
{/* Dropdown Items */}
{dropdownNavigation.map((link) => (
<Link href={link.href} target={link.target} className="flex w-full items-center">
<DropdownMenuItem>
<link.icon className="mr-2 h-4 w-4" strokeWidth={1.5} />
{link.label}
</DropdownMenuItem>
</Link>
))}
{/* Logout */}
<DropdownMenuItem
onClick={async () => {
await signOut({ callbackUrl: "/auth/login" });
await formbricksLogout();
}}
icon={<LogOutIcon className="h-4 w-4" strokeWidth={1.5} />}>
{t("common.logout")}
</DropdownMenuItem>
{/* Organization Switch */}
{(isMultiOrgEnabled || organizations.length > 1) && (
<DropdownMenuSub>
<DropdownMenuSubTrigger className="rounded-lg">
<div>
<p>{currentOrganizationName}</p>
<p className="block text-xs text-slate-500">{t("common.switch_organization")}</p>
</div>
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent sideOffset={10} alignOffset={5}>
<DropdownMenuRadioGroup
value={currentOrganizationId}
onValueChange={(organizationId) =>
handleEnvironmentChangeByOrganization(organizationId)
}>
{sortedOrganizations.map((organization) => (
<DropdownMenuRadioItem
value={organization.id}
className="cursor-pointer rounded-lg"
key={organization.id}>
{organization.name}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
{isMultiOrgEnabled && (
<DropdownMenuItem
onClick={() => setOpenCreateOrganizationModal(true)}
icon={<PlusIcon className="mr-2 h-4 w-4" />}>
<span>{t("common.create_new_organization")}</span>
</DropdownMenuItem>
)}
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
<CreateOrganizationModal open={openCreateOrganizationModal} setOpen={setOpenCreateOrganizationModal} />
</aside>
);
};

View File

@@ -0,0 +1,35 @@
import { getServerSession } from "next-auth";
import { notFound, redirect } from "next/navigation";
import { authOptions } from "@formbricks/lib/authOptions";
import { getEnvironments } from "@formbricks/lib/environment/service";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getUserProducts } from "@formbricks/lib/product/service";
const LandingLayout = async ({ children, params }) => {
const session = await getServerSession(authOptions);
if (!session || !session.user) {
return redirect(`/auth/login`);
}
const membership = await getMembershipByUserIdOrganizationId(session.user.id, params.organizationId);
if (!membership) {
return notFound();
}
const products = await getUserProducts(session.user.id, params.organizationId);
if (products.length !== 0) {
const firstProduct = products[0];
const environments = await getEnvironments(firstProduct.id);
const prodEnvironment = environments.find((e) => e.type === "production");
if (prodEnvironment) {
return redirect(`/environments/${prodEnvironment.id}/`);
}
}
return <>{children}</>;
};
export default LandingLayout;

View File

@@ -0,0 +1,50 @@
import { LandingSidebar } from "@/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar";
import { getServerSession } from "next-auth";
import { getTranslations } from "next-intl/server";
import { notFound, redirect } from "next/navigation";
import { getEnterpriseLicense } from "@formbricks/ee/lib/service";
import { authOptions } from "@formbricks/lib/authOptions";
import { getOrganization, getOrganizationsByUserId } from "@formbricks/lib/organization/service";
import { getUser } from "@formbricks/lib/user/service";
import { Header } from "@formbricks/ui/components/Header";
const Page = async ({ params }) => {
const t = await getTranslations();
const session = await getServerSession(authOptions);
if (!session || !session.user) {
return redirect(`/auth/login`);
}
const user = await getUser(session.user.id);
if (!user) return notFound();
const organization = await getOrganization(params.organizationId);
if (!organization) return notFound();
const organizations = await getOrganizationsByUserId(session.user.id);
const { features } = await getEnterpriseLicense();
const isMultiOrgEnabled = features?.isMultiOrgEnabled ?? false;
return (
<div className="flex min-h-full min-w-full flex-row">
<LandingSidebar
user={user}
organization={organization}
isMultiOrgEnabled={isMultiOrgEnabled}
organizations={organizations}
/>
<div className="flex-1">
<div className="flex h-full flex-col items-center justify-center space-y-12">
<Header
title={t("organizations.landing.no_products_warning_title")}
subtitle={t("organizations.landing.no_products_warning_subtitle")}
/>
</div>
</div>
</div>
);
};
export default Page;

View File

@@ -1,8 +1,8 @@
import { PosthogIdentify } from "@/app/(app)/environments/[environmentId]/components/PosthogIdentify";
import { getServerSession } from "next-auth";
import { notFound, redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { redirect } from "next/navigation";
import { authOptions } from "@formbricks/lib/authOptions";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { canUserAccessOrganization } from "@formbricks/lib/organization/auth";
import { getOrganization } from "@formbricks/lib/organization/service";
import { getUser } from "@formbricks/lib/user/service";
@@ -10,6 +10,7 @@ import { AuthorizationError } from "@formbricks/types/errors";
import { ToasterClient } from "@formbricks/ui/components/ToasterClient";
const ProductOnboardingLayout = async ({ children, params }) => {
const t = await getTranslations();
const session = await getServerSession(authOptions);
if (!session || !session.user) {
return redirect(`/auth/login`);
@@ -17,7 +18,7 @@ const ProductOnboardingLayout = async ({ children, params }) => {
const user = await getUser(session.user.id);
if (!user) {
throw new Error("User not found");
throw new Error(t("common.user_not_found"));
}
const isAuthorized = await canUserAccessOrganization(session.user.id, params.organizationId);
@@ -25,12 +26,9 @@ const ProductOnboardingLayout = async ({ children, params }) => {
throw AuthorizationError;
}
const membership = await getMembershipByUserIdOrganizationId(session.user.id, params.organizationId);
if (!membership || membership.role === "viewer") return notFound();
const organization = await getOrganization(params.organizationId);
if (!organization) {
throw new Error("Organization not found");
throw new Error(t("common.organization_not_found"));
}
return (

View File

@@ -0,0 +1,20 @@
import { getServerSession } from "next-auth";
import { notFound, redirect } from "next/navigation";
import { authOptions } from "@formbricks/lib/authOptions";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
const OnboardingLayout = async ({ children, params }) => {
const session = await getServerSession(authOptions);
if (!session || !session.user) {
return redirect(`/auth/login`);
}
const membership = await getMembershipByUserIdOrganizationId(session.user.id, params.organizationId);
const { isMember, isBilling } = getAccessFlags(membership?.role);
if (isMember || isBilling) return notFound();
return <>{children}</>;
};
export default OnboardingLayout;

View File

@@ -1,6 +1,10 @@
import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer";
import { GlobeIcon, GlobeLockIcon, LinkIcon, XIcon } from "lucide-react";
import { getProducts } from "@formbricks/lib/product/service";
import { getServerSession } from "next-auth";
import { getTranslations } from "next-intl/server";
import { redirect } from "next/navigation";
import { authOptions } from "@formbricks/lib/authOptions";
import { getUserProducts } from "@formbricks/lib/product/service";
import { Button } from "@formbricks/ui/components/Button";
import { Header } from "@formbricks/ui/components/Header";
@@ -11,38 +15,44 @@ interface ChannelPageProps {
}
const Page = async ({ params }: ChannelPageProps) => {
const session = await getServerSession(authOptions);
if (!session || !session.user) {
return redirect(`/auth/login`);
}
const t = await getTranslations();
const channelOptions = [
{
title: "Public website",
description: "Run well-timed pop-up surveys.",
title: t("organizations.products.new.channel.public_website"),
description: t("organizations.products.new.channel.public_website_description"),
icon: GlobeIcon,
iconText: "Built for scale",
iconText: t("organizations.products.new.channel.public_website_icon_text"),
href: `/organizations/${params.organizationId}/products/new/settings?channel=website`,
},
{
title: "App with sign up",
description: "Run highly-targeted micro-surveys.",
title: t("organizations.products.new.channel.app_with_sign_up"),
description: t("organizations.products.new.channel.app_with_sign_up_description"),
icon: GlobeLockIcon,
iconText: "Enrich user profiles",
iconText: t("organizations.products.new.channel.app_with_sign_up_icon_text"),
href: `/organizations/${params.organizationId}/products/new/settings?channel=app`,
},
{
channel: "link",
title: "Link & email surveys",
description: "Reach people anywhere online.",
title: t("organizations.products.new.channel.link_and_email_surveys"),
description: t("organizations.products.new.channel.link_and_email_surveys_description"),
icon: LinkIcon,
iconText: "Anywhere online",
iconText: t("organizations.products.new.channel.link_and_email_surveys_icon_text"),
href: `/organizations/${params.organizationId}/products/new/settings?channel=link`,
},
];
const products = await getProducts(params.organizationId);
const products = await getUserProducts(session.user.id, params.organizationId);
return (
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
<Header
title="Where do you mainly want to survey people?"
subtitle="Run surveys on public websites, in your app, or with shareable links & emails."
title={t("organizations.products.new.channel.channel_select_title")}
subtitle={t("organizations.products.new.channel.channel_select_subtitle")}
/>
<OnboardingOptionsContainer options={channelOptions} />
{products.length >= 1 && (

View File

@@ -1,69 +0,0 @@
import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer";
import { HeartIcon, MonitorIcon, ShoppingCart, XIcon } from "lucide-react";
import { notFound } from "next/navigation";
import { getProducts } from "@formbricks/lib/product/service";
import { TProductConfigChannel } from "@formbricks/types/product";
import { Button } from "@formbricks/ui/components/Button";
import { Header } from "@formbricks/ui/components/Header";
interface IndustryPageProps {
params: {
organizationId: string;
};
searchParams: {
channel?: TProductConfigChannel;
};
}
const Page = async ({ params, searchParams }: IndustryPageProps) => {
const channel = searchParams.channel;
if (!channel) {
return notFound();
}
const products = await getProducts(params.organizationId);
const industryOptions = [
{
title: "E-Commerce",
description: "Understand why people buy.",
icon: ShoppingCart,
iconText: "B2B and B2C",
href: `/organizations/${params.organizationId}/products/new/settings?channel=${channel}&industry=eCommerce`,
},
{
title: "SaaS",
description: "Improve product-market fit.",
icon: MonitorIcon,
iconText: "Proven methods",
href: `/organizations/${params.organizationId}/products/new/settings?channel=${channel}&industry=saas`,
},
{
title: "Other",
description: "Listen to your customers.",
icon: HeartIcon,
iconText: "Customer insights",
href: `/organizations/${params.organizationId}/products/new/settings?channel=${channel}&industry=other`,
},
];
return (
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
<Header
title="Which industry do you work for?"
subtitle="Get started with battle-tested best practices."
/>
<OnboardingOptionsContainer options={industryOptions} />
{products.length >= 1 && (
<Button
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="minimal"
href={"/"}>
<XIcon className="h-7 w-7" strokeWidth={1.5} />
</Button>
)}
</div>
);
};
export default Page;

View File

@@ -1,6 +1,10 @@
import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer";
import { HeartIcon, ListTodoIcon, XIcon } from "lucide-react";
import { getProducts } from "@formbricks/lib/product/service";
import { getServerSession } from "next-auth";
import { getTranslations } from "next-intl/server";
import { redirect } from "next/navigation";
import { authOptions } from "@formbricks/lib/authOptions";
import { getUserProducts } from "@formbricks/lib/product/service";
import { Button } from "@formbricks/ui/components/Button";
import { Header } from "@formbricks/ui/components/Header";
@@ -11,26 +15,32 @@ interface ModePageProps {
}
const Page = async ({ params }: ModePageProps) => {
const session = await getServerSession(authOptions);
if (!session || !session.user) {
return redirect(`/auth/login`);
}
const t = await getTranslations();
const channelOptions = [
{
title: "Formbricks Surveys",
description: "Multi-purpose survey platform for web, app and email surveys.",
title: t("organizations.products.new.mode.formbricks_surveys"),
description: t("organizations.products.new.mode.formbricks_surveys_description"),
icon: ListTodoIcon,
href: `/organizations/${params.organizationId}/products/new/channel`,
},
{
title: "Formbricks CX",
description: "Surveys and reports to understand what your customers need.",
title: t("organizations.products.new.mode.formbricks_cx"),
description: t("organizations.products.new.mode.formbricks_cx_description"),
icon: HeartIcon,
href: `/organizations/${params.organizationId}/products/new/settings?mode=cx`,
},
];
const products = await getProducts(params.organizationId);
const products = await getUserProducts(session.user.id, params.organizationId);
return (
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
<Header title="What are you here for?" />
<Header title={t("organizations.products.new.mode.what_are_you_here_for")} />
<OnboardingOptionsContainer options={channelOptions} />
{products.length >= 1 && (
<Button

View File

@@ -1,14 +1,18 @@
"use client";
import { createProductAction } from "@/app/(app)/environments/[environmentId]/actions";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { TOrganizationTeam } from "@/modules/ee/teams/product-teams/types/teams";
import { CreateTeamModal } from "@/modules/ee/teams/team-list/components/create-team-modal";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslations } from "next-intl";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "react-hot-toast";
import { getFormattedErrorMessage } from "@formbricks/lib/actionClient/helper";
import { FORMBRICKS_SURVEYS_FILTERS_KEY_LS } from "@formbricks/lib/localStorage";
import { PREVIEW_SURVEY } from "@formbricks/lib/styling/constants";
import { getPreviewSurvey } from "@formbricks/lib/styling/constants";
import {
TProductConfigChannel,
TProductConfigIndustry,
@@ -28,6 +32,7 @@ import {
FormProvider,
} from "@formbricks/ui/components/Form";
import { Input } from "@formbricks/ui/components/Input";
import { MultiSelect } from "@formbricks/ui/components/MultiSelect";
import { SurveyInline } from "@formbricks/ui/components/Survey";
interface ProductSettingsProps {
@@ -36,6 +41,9 @@ interface ProductSettingsProps {
channel: TProductConfigChannel;
industry: TProductConfigIndustry;
defaultBrandColor: string;
organizationTeams: TOrganizationTeam[];
canDoRoleManagement: boolean;
locale: string;
}
export const ProductSettings = ({
@@ -44,9 +52,14 @@ export const ProductSettings = ({
channel,
industry,
defaultBrandColor,
organizationTeams,
canDoRoleManagement = false,
locale,
}: ProductSettingsProps) => {
const router = useRouter();
const [createTeamModalOpen, setCreateTeamModalOpen] = useState(false);
const router = useRouter();
const t = useTranslations();
const addProduct = async (data: TProductUpdateInput) => {
try {
const createProductResponse = await createProductAction({
@@ -54,6 +67,7 @@ export const ProductSettings = ({
data: {
...data,
config: { channel, industry },
teamIds: data.teamIds,
},
});
@@ -89,6 +103,7 @@ export const ProductSettings = ({
defaultValues: {
name: "",
styling: { allowStyleOverwrite: true, brandColor: { light: defaultBrandColor } },
teamIds: [],
},
resolver: zodResolver(ZProductUpdateInput),
});
@@ -96,6 +111,11 @@ export const ProductSettings = ({
const brandColor = form.watch("styling.brandColor.light") ?? defaultBrandColor;
const { isSubmitting } = form.formState;
const organizationTeamsOptions = organizationTeams.map((team) => ({
label: team.name,
value: team.id,
}));
return (
<div className="mt-6 flex w-5/6 space-x-10 lg:w-2/3 2xl:w-1/2">
<div className="flex w-1/2 flex-col space-y-4">
@@ -107,8 +127,10 @@ export const ProductSettings = ({
render={({ field, fieldState: { error } }) => (
<FormItem className="w-full space-y-4">
<div>
<FormLabel>Brand color</FormLabel>
<FormDescription>Match the main color of surveys with your brand.</FormDescription>
<FormLabel>{t("organizations.products.new.settings.brand_color")}</FormLabel>
<FormDescription>
{t("organizations.products.new.settings.brand_color_description")}
</FormDescription>
</div>
<FormControl>
<div>
@@ -129,8 +151,10 @@ export const ProductSettings = ({
render={({ field, fieldState: { error } }) => (
<FormItem className="w-full space-y-4">
<div>
<FormLabel>Product name</FormLabel>
<FormDescription>What is your product called?</FormDescription>
<FormLabel>{t("organizations.products.new.settings.product_name")}</FormLabel>
<FormDescription>
{t("organizations.products.new.settings.product_name_description")}
</FormDescription>
</div>
<FormControl>
<div>
@@ -148,9 +172,42 @@ export const ProductSettings = ({
)}
/>
{canDoRoleManagement && (
<FormField
control={form.control}
name="teamIds"
render={({ field, fieldState: { error } }) => (
<FormItem className="w-full space-y-4">
<div className="flex items-center justify-between">
<div>
<FormLabel>Teams</FormLabel>
<FormDescription>Who all can access this product?</FormDescription>
</div>
<Button
variant="secondary"
size="sm"
type="button"
onClick={() => setCreateTeamModalOpen(true)}>
{t("organizations.products.new.settings.create_new_team")}
</Button>
</div>
<FormControl>
<div>
<MultiSelect
value={field.value}
onChange={(teamIds) => field.onChange(teamIds)}
options={organizationTeamsOptions}
/>
{error?.message && <FormError className="text-left">{error.message}</FormError>}
</div>
</FormControl>
</FormItem>
)}
/>
)}
<div className="flex w-full justify-end">
<Button loading={isSubmitting} type="submit">
Next
<Button loading={isSubmitting} type="submit" id="form-next-button">
{t("common.next")}
</Button>
</div>
</form>
@@ -167,10 +224,10 @@ export const ProductSettings = ({
className="absolute left-2 top-2 -mb-6 h-20 w-auto max-w-64 rounded-lg border object-contain p-1"
/>
)}
<p className="text-sm text-slate-400">Preview</p>
<div className="h-3/4 w-3/4">
<p className="text-sm text-slate-400">{t("common.preview")}</p>
<div className="z-0 h-3/4 w-3/4">
<SurveyInline
survey={PREVIEW_SURVEY}
survey={getPreviewSurvey(locale)}
styling={{ brandColor: { light: brandColor } }}
isBrandingEnabled={false}
languageCode="default"
@@ -179,6 +236,14 @@ export const ProductSettings = ({
/>
</div>
</div>
<CreateTeamModal
open={createTeamModalOpen}
setOpen={setCreateTeamModalOpen}
organizationId={organizationId}
onCreate={(teamId) => {
form.setValue("teamIds", [...(form.getValues("teamIds") || []), teamId]);
}}
/>
</div>
);
};

View File

@@ -1,8 +1,16 @@
import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboarding";
import { getCustomHeadline } from "@/app/(app)/(onboarding)/lib/utils";
import { ProductSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/products/new/settings/components/ProductSettings";
import { XIcon } from "lucide-react";
import { DEFAULT_BRAND_COLOR } from "@formbricks/lib/constants";
import { getProducts } from "@formbricks/lib/product/service";
import { getServerSession } from "next-auth";
import { getTranslations } from "next-intl/server";
import { redirect } from "next/navigation";
import { getRoleManagementPermission } from "@formbricks/ee/lib/service";
import { authOptions } from "@formbricks/lib/authOptions";
import { DEFAULT_BRAND_COLOR, DEFAULT_LOCALE } from "@formbricks/lib/constants";
import { getOrganization } from "@formbricks/lib/organization/service";
import { getUserProducts } from "@formbricks/lib/product/service";
import { getUserLocale } from "@formbricks/lib/user/service";
import { TProductConfigChannel, TProductConfigIndustry, TProductMode } from "@formbricks/types/product";
import { Button } from "@formbricks/ui/components/Button";
import { Header } from "@formbricks/ui/components/Header";
@@ -19,24 +27,45 @@ interface ProductSettingsPageProps {
}
const Page = async ({ params, searchParams }: ProductSettingsPageProps) => {
const t = await getTranslations();
const session = await getServerSession(authOptions);
if (!session || !session.user) {
return redirect(`/auth/login`);
}
const channel = searchParams.channel || null;
const industry = searchParams.industry || null;
const mode = searchParams.mode || "surveys";
const locale = session?.user.id ? await getUserLocale(session.user.id) : undefined;
const customHeadline = getCustomHeadline(channel);
const products = await getProducts(params.organizationId);
const products = await getUserProducts(session.user.id, params.organizationId);
const organizationTeams = await getTeamsByOrganizationId(params.organizationId);
const organization = await getOrganization(params.organizationId);
if (!organization) {
throw new Error(t("common.organization_not_found"));
}
const canDoRoleManagement = await getRoleManagementPermission(organization);
if (!organizationTeams) {
throw new Error(t("common.organization_teams_not_found"));
}
return (
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
{channel === "link" || mode === "cx" ? (
<Header
title="Match your brand, get 2x more responses."
subtitle="When people recognize your brand, they are much more likely to start and complete responses."
title={t("organizations.products.new.settings.channel_settings_title")}
subtitle={t("organizations.products.new.settings.channel_settings_subtitle")}
/>
) : (
<Header
title={customHeadline}
subtitle="Get 2x more responses matching surveys with your brand and UI"
title={t(customHeadline)}
subtitle={t("organizations.products.new.settings.channel_settings_description")}
/>
)}
<ProductSettings
@@ -45,6 +74,9 @@ const Page = async ({ params, searchParams }: ProductSettingsPageProps) => {
channel={channel}
industry={industry}
defaultBrandColor={DEFAULT_BRAND_COLOR}
organizationTeams={organizationTeams}
canDoRoleManagement={canDoRoleManagement}
locale={locale ?? DEFAULT_LOCALE}
/>
{products.length >= 1 && (
<Button

View File

@@ -1,19 +1,19 @@
"use server";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
import { sendInviteMemberEmail } from "@/modules/email";
import { z } from "zod";
import { sendInviteMemberEmail } from "@formbricks/email";
import { authenticatedActionClient } from "@formbricks/lib/actionClient";
import { checkAuthorization } from "@formbricks/lib/actionClient/utils";
import { INVITE_DISABLED } from "@formbricks/lib/constants";
import { inviteUser } from "@formbricks/lib/invite/service";
import { ZId } from "@formbricks/types/common";
import { AuthenticationError } from "@formbricks/types/errors";
import { ZMembershipRole } from "@formbricks/types/memberships";
import { ZOrganizationRole } from "@formbricks/types/memberships";
const ZInviteOrganizationMemberAction = z.object({
organizationId: ZId,
email: z.string(),
role: ZMembershipRole,
role: ZOrganizationRole,
inviteMessage: z.string(),
});
@@ -24,10 +24,15 @@ export const inviteOrganizationMemberAction = authenticatedActionClient
throw new AuthenticationError("Invite disabled");
}
await checkAuthorization({
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: parsedInput.organizationId,
rules: ["membership", "create"],
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
],
});
const invite = await inviteUser({
@@ -46,7 +51,8 @@ export const inviteOrganizationMemberAction = authenticatedActionClient
ctx.user.name ?? "",
"",
true, // is onboarding invite
parsedInput.inviteMessage
parsedInput.inviteMessage,
ctx.user.locale
);
}

View File

@@ -0,0 +1,8 @@
import { z } from "zod";
export const ZOrganizationTeam = z.object({
id: z.string().cuid2(),
name: z.string(),
});
export type TOrganizationTeam = z.infer<typeof ZOrganizationTeam>;

View File

@@ -2,6 +2,7 @@ import { FormbricksClient } from "@/app/(app)/components/FormbricksClient";
import { PosthogIdentify } from "@/app/(app)/environments/[environmentId]/components/PosthogIdentify";
import { ResponseFilterProvider } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
import { getServerSession } from "next-auth";
import { getTranslations } from "next-intl/server";
import { redirect } from "next/navigation";
import { authOptions } from "@formbricks/lib/authOptions";
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
@@ -13,6 +14,7 @@ import { DevEnvironmentBanner } from "@formbricks/ui/components/DevEnvironmentBa
import { ToasterClient } from "@formbricks/ui/components/ToasterClient";
const SurveyEditorEnvironmentLayout = async ({ children, params }) => {
const t = await getTranslations();
const session = await getServerSession(authOptions);
if (!session || !session.user) {
return redirect(`/auth/login`);
@@ -20,23 +22,23 @@ const SurveyEditorEnvironmentLayout = async ({ children, params }) => {
const user = await getUser(session.user.id);
if (!user) {
throw new Error("User not found");
throw new Error(t("common.user_not_found"));
}
const hasAccess = await hasUserEnvironmentAccess(session.user.id, params.environmentId);
if (!hasAccess) {
throw new AuthorizationError("Not authorized");
throw new AuthorizationError(t("common.not_authorized"));
}
const organization = await getOrganizationByEnvironmentId(params.environmentId);
if (!organization) {
throw new Error("Organization not found");
throw new Error(t("common.organization_not_found"));
}
const environment = await getEnvironment(params.environmentId);
if (!environment) {
throw new Error("Environment not found");
throw new Error(t("common.environment_not_found"));
}
return (

View File

@@ -1,16 +1,20 @@
"use server";
import { z } from "zod";
import { createActionClass } from "@formbricks/lib/actionClass/service";
import { actionClient, authenticatedActionClient } from "@formbricks/lib/actionClient";
import { checkAuthorization } from "@formbricks/lib/actionClient/utils";
import { UNSPLASH_ACCESS_KEY, UNSPLASH_ALLOWED_DOMAINS } from "@formbricks/lib/constants";
import { actionClient, authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
import {
getOrganizationIdFromEnvironmentId,
getOrganizationIdFromProductId,
getOrganizationIdFromSegmentId,
getOrganizationIdFromSurveyId,
} from "@formbricks/lib/organization/utils";
getProductIdFromEnvironmentId,
getProductIdFromSegmentId,
getProductIdFromSurveyId,
} from "@/lib/utils/helper";
import { getSegment, getSurvey } from "@/lib/utils/services";
import { z } from "zod";
import { createActionClass } from "@formbricks/lib/actionClass/service";
import { UNSPLASH_ACCESS_KEY, UNSPLASH_ALLOWED_DOMAINS } from "@formbricks/lib/constants";
import { getProduct } from "@formbricks/lib/product/service";
import {
cloneSegment,
@@ -28,11 +32,22 @@ import { ZSurvey } from "@formbricks/types/surveys/types";
export const updateSurveyAction = authenticatedActionClient
.schema(ZSurvey)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorization({
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromSurveyId(parsedInput.id),
rules: ["survey", "update"],
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "productTeam",
productId: await getProductIdFromSurveyId(parsedInput.id),
minPermission: "readWrite",
},
],
});
return await updateSurvey(parsedInput);
});
@@ -43,10 +58,20 @@ const ZRefetchProductAction = z.object({
export const refetchProductAction = authenticatedActionClient
.schema(ZRefetchProductAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorization({
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromProductId(parsedInput.productId),
rules: ["product", "read"],
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "productTeam",
minPermission: "readWrite",
productId: parsedInput.productId,
},
],
});
return await getProduct(parsedInput.productId);
@@ -64,10 +89,30 @@ const ZCreateBasicSegmentAction = z.object({
export const createBasicSegmentAction = authenticatedActionClient
.schema(ZCreateBasicSegmentAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorization({
const surveyEnvironment = await getSurvey(parsedInput.surveyId);
if (!surveyEnvironment) {
throw new Error("Survey not found");
}
if (surveyEnvironment.environmentId !== parsedInput.environmentId) {
throw new Error("Survey and segment are not in the same environment");
}
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromEnvironmentId(parsedInput.environmentId),
rules: ["segment", "create"],
organizationId: await getOrganizationIdFromEnvironmentId(surveyEnvironment.environmentId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "productTeam",
minPermission: "readWrite",
productId: await getProductIdFromSurveyId(parsedInput.surveyId),
},
],
});
const parsedFilters = ZSegmentFilters.safeParse(parsedInput.filters);
@@ -99,10 +144,22 @@ const ZUpdateBasicSegmentAction = z.object({
export const updateBasicSegmentAction = authenticatedActionClient
.schema(ZUpdateBasicSegmentAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorization({
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromSegmentId(parsedInput.segmentId),
rules: ["segment", "update"],
access: [
{
schema: ZSegmentUpdateInput,
data: parsedInput.data,
type: "organization",
roles: ["owner", "manager"],
},
{
type: "productTeam",
minPermission: "readWrite",
productId: await getProductIdFromSegmentId(parsedInput.segmentId),
},
],
});
const { filters } = parsedInput.data;
@@ -127,16 +184,36 @@ const ZLoadNewBasicSegmentAction = z.object({
export const loadNewBasicSegmentAction = authenticatedActionClient
.schema(ZLoadNewBasicSegmentAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorization({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromSegmentId(parsedInput.surveyId),
rules: ["segment", "read"],
});
const surveyEnvironment = await getSurvey(parsedInput.surveyId);
const segmentEnvironment = await getSegment(parsedInput.segmentId);
await checkAuthorization({
if (!surveyEnvironment || !segmentEnvironment) {
if (!surveyEnvironment) {
throw new Error("Survey not found");
}
if (!segmentEnvironment) {
throw new Error("Segment not found");
}
}
if (surveyEnvironment.environmentId !== segmentEnvironment.environmentId) {
throw new Error("Segment and survey are not in the same environment");
}
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId),
rules: ["survey", "update"],
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "productTeam",
minPermission: "readWrite",
productId: await getProductIdFromSurveyId(parsedInput.surveyId),
},
],
});
return await loadNewSegmentInSurvey(parsedInput.surveyId, parsedInput.segmentId);
@@ -150,16 +227,36 @@ const ZCloneBasicSegmentAction = z.object({
export const cloneBasicSegmentAction = authenticatedActionClient
.schema(ZCloneBasicSegmentAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorization({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromSegmentId(parsedInput.segmentId),
rules: ["segment", "create"],
});
const surveyEnvironment = await getSurvey(parsedInput.surveyId);
const segmentEnvironment = await getSegment(parsedInput.segmentId);
await checkAuthorization({
if (!surveyEnvironment || !segmentEnvironment) {
if (!surveyEnvironment) {
throw new Error("Survey not found");
}
if (!segmentEnvironment) {
throw new Error("Segment not found");
}
}
if (surveyEnvironment.environmentId !== segmentEnvironment.environmentId) {
throw new Error("Segment and survey are not in the same environment");
}
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId),
rules: ["survey", "read"],
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "productTeam",
minPermission: "readWrite",
productId: await getProductIdFromSurveyId(parsedInput.surveyId),
},
],
});
return await cloneSegment(parsedInput.segmentId, parsedInput.surveyId);
@@ -172,10 +269,20 @@ const ZResetBasicSegmentFiltersAction = z.object({
export const resetBasicSegmentFiltersAction = authenticatedActionClient
.schema(ZResetBasicSegmentFiltersAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorization({
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId),
rules: ["segment", "update"],
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "productTeam",
minPermission: "readWrite",
productId: await getProductIdFromSurveyId(parsedInput.surveyId),
},
],
});
return await resetSegmentInSurvey(parsedInput.surveyId);
@@ -267,10 +374,20 @@ const ZCreateActionClassAction = z.object({
export const createActionClassAction = authenticatedActionClient
.schema(ZCreateActionClassAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorization({
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromEnvironmentId(parsedInput.action.environmentId),
rules: ["actionClass", "create"],
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "productTeam",
minPermission: "readWrite",
productId: await getProductIdFromEnvironmentId(parsedInput.action.environmentId),
},
],
});
return await createActionClass(parsedInput.action.environmentId, parsedInput.action);

View File

@@ -1,5 +1,6 @@
"use client";
import { useTranslations } from "next-intl";
import { TActionClass } from "@formbricks/types/action-classes";
import { TSurvey } from "@formbricks/types/surveys/types";
import { ModalWithTabs } from "@formbricks/ui/components/ModalWithTabs";
@@ -12,7 +13,7 @@ interface AddActionModalProps {
environmentId: string;
actionClasses: TActionClass[];
setActionClasses: React.Dispatch<React.SetStateAction<TActionClass[]>>;
isViewer: boolean;
isReadOnly: boolean;
localSurvey: TSurvey;
setLocalSurvey: React.Dispatch<React.SetStateAction<TSurvey>>;
}
@@ -24,12 +25,13 @@ export const AddActionModal = ({
setActionClasses,
localSurvey,
setLocalSurvey,
isViewer,
isReadOnly,
environmentId,
}: AddActionModalProps) => {
const t = useTranslations();
const tabs = [
{
title: "Select saved action",
title: t("environments.surveys.edit.select_saved_action"),
children: (
<SavedActionsTab
actionClasses={actionClasses}
@@ -40,13 +42,13 @@ export const AddActionModal = ({
),
},
{
title: "Capture new action",
title: t("environments.surveys.edit.capture_new_action"),
children: (
<CreateNewActionTab
actionClasses={actionClasses}
setActionClasses={setActionClasses}
setOpen={setOpen}
isViewer={isViewer}
isReadOnly={isReadOnly}
setLocalSurvey={setLocalSurvey}
environmentId={environmentId}
/>
@@ -55,8 +57,8 @@ export const AddActionModal = ({
];
return (
<ModalWithTabs
label="Add action"
description="Capture a new action to trigger a survey on."
label={t("common.add_action")}
description={t("environments.surveys.edit.capture_a_new_action_to_trigger_a_survey_on")}
open={open}
setOpen={setOpen}
tabs={tabs}

View File

@@ -1,6 +1,7 @@
"use client";
import { PlusIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { TSurvey } from "@formbricks/types/surveys/types";
interface AddEndingCardButtonProps {
@@ -10,6 +11,7 @@ interface AddEndingCardButtonProps {
}
export const AddEndingCardButton = ({ localSurvey, addEndingCard }: AddEndingCardButtonProps) => {
const t = useTranslations();
return (
<div
className="group inline-flex rounded-lg border border-slate-300 bg-slate-50 hover:cursor-pointer hover:bg-white"
@@ -18,7 +20,7 @@ export const AddEndingCardButton = ({ localSurvey, addEndingCard }: AddEndingCar
<PlusIcon className="h-6 w-6 text-white" />
</div>
<div className="px-4 py-3 text-sm">
<p className="font-semibold">Add ending</p>
<p className="font-semibold">{t("environments.surveys.edit.add_ending")}</p>
</div>
</div>
);

View File

@@ -4,12 +4,13 @@ import { useAutoAnimate } from "@formkit/auto-animate/react";
import { createId } from "@paralleldrive/cuid2";
import * as Collapsible from "@radix-ui/react-collapsible";
import { PlusIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { useState } from "react";
import { cn } from "@formbricks/lib/cn";
import {
CXQuestionTypes,
getCXQuestionTypes,
getQuestionDefaults,
questionTypes,
getQuestionTypes,
universalQuestionPresets,
} from "@formbricks/lib/utils/questions";
import { TProduct } from "@formbricks/types/product";
@@ -18,15 +19,16 @@ interface AddQuestionButtonProps {
addQuestion: (question: any) => void;
product: TProduct;
isCxMode: boolean;
locale: string;
}
export const AddQuestionButton = ({ addQuestion, product, isCxMode }: AddQuestionButtonProps) => {
export const AddQuestionButton = ({ addQuestion, product, isCxMode, locale }: AddQuestionButtonProps) => {
const t = useTranslations();
const [open, setOpen] = useState(false);
const [hoveredQuestionId, setHoveredQuestionId] = useState<string | null>(null);
const availableQuestionTypes = isCxMode ? getCXQuestionTypes(locale) : getQuestionTypes(locale);
const [parent] = useAutoAnimate();
const availableQuestionTypes = isCxMode ? CXQuestionTypes : questionTypes;
return (
<Collapsible.Root
open={open}
@@ -41,8 +43,10 @@ export const AddQuestionButton = ({ addQuestion, product, isCxMode }: AddQuestio
<PlusIcon className="h-5 w-5 text-white" />
</div>
<div className="px-4 py-3">
<p className="text-sm font-semibold">Add question</p>
<p className="mt-1 text-xs text-slate-500">Add a new question to your survey</p>
<p className="text-sm font-semibold">{t("environments.surveys.edit.add_question")}</p>
<p className="mt-1 text-xs text-slate-500">
{t("environments.surveys.edit.add_a_new_question_to_your_survey")}
</p>
</div>
</div>
</Collapsible.CollapsibleTrigger>
@@ -56,7 +60,7 @@ export const AddQuestionButton = ({ addQuestion, product, isCxMode }: AddQuestio
onClick={() => {
addQuestion({
...universalQuestionPresets,
...getQuestionDefaults(questionType.id, product),
...getQuestionDefaults(questionType.id, product, locale),
id: createId(),
type: questionType.id,
});

View File

@@ -1,13 +1,15 @@
"use client";
import { QuestionFormInput } from "@/modules/surveys/components/QuestionFormInput";
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { PlusIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { useEffect } from "react";
import { createI18nString, extractLanguageCodes } from "@formbricks/lib/i18n/utils";
import { TAttributeClass } from "@formbricks/types/attribute-classes";
import { TSurvey, TSurveyAddressQuestion } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { Button } from "@formbricks/ui/components/Button";
import { QuestionFormInput } from "@formbricks/ui/components/QuestionFormInput";
import { QuestionToggleTable } from "@formbricks/ui/components/QuestionToggleTable";
interface AddressQuestionFormProps {
@@ -20,6 +22,7 @@ interface AddressQuestionFormProps {
selectedLanguageCode: string;
setSelectedLanguageCode: (language: string) => void;
attributeClasses: TAttributeClass[];
locale: TUserLocale;
}
export const AddressQuestionForm = ({
@@ -31,38 +34,39 @@ export const AddressQuestionForm = ({
selectedLanguageCode,
setSelectedLanguageCode,
attributeClasses,
locale,
}: AddressQuestionFormProps): JSX.Element => {
const surveyLanguageCodes = extractLanguageCodes(localSurvey.languages ?? []);
const t = useTranslations();
const fields = [
{
id: "addressLine1",
label: "Address Line 1",
label: t("environments.surveys.edit.address_line_1"),
...question.addressLine1,
},
{
id: "addressLine2",
label: "Address Line 2",
label: t("environments.surveys.edit.address_line_2"),
...question.addressLine2,
},
{
id: "city",
label: "City",
label: t("environments.surveys.edit.city"),
...question.city,
},
{
id: "state",
label: "State",
label: t("environments.surveys.edit.state"),
...question.state,
},
{
id: "zip",
label: "Zip",
label: t("environments.surveys.edit.zip"),
...question.zip,
},
{
id: "country",
label: "Country",
label: t("environments.surveys.edit.country"),
...question.country,
},
];
@@ -98,7 +102,7 @@ export const AddressQuestionForm = ({
<QuestionFormInput
id="headline"
value={question.headline}
label={"Question*"}
label={t("environments.surveys.edit.question") + "*"}
localSurvey={localSurvey}
questionIdx={questionIdx}
isInvalid={isInvalid}
@@ -106,6 +110,7 @@ export const AddressQuestionForm = ({
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
attributeClasses={attributeClasses}
locale={locale}
/>
<div ref={parent}>
@@ -115,7 +120,7 @@ export const AddressQuestionForm = ({
<QuestionFormInput
id="subheader"
value={question.subheader}
label={"Description"}
label={t("common.description")}
localSurvey={localSurvey}
questionIdx={questionIdx}
isInvalid={isInvalid}
@@ -123,6 +128,7 @@ export const AddressQuestionForm = ({
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
attributeClasses={attributeClasses}
locale={locale}
/>
</div>
</div>
@@ -139,7 +145,7 @@ export const AddressQuestionForm = ({
});
}}>
<PlusIcon className="mr-1 h-4 w-4" />
Add Description
{t("environments.surveys.edit.add_description")}
</Button>
)}

View File

@@ -48,6 +48,9 @@ export const AnimatedSurveyBg = ({ handleBgChange, background }: AnimatedSurveyB
"/animated-bgs/Thumbnails/36_Thumb.mp4": "/animated-bgs/4K/36_4k.mp4",
"/animated-bgs/Thumbnails/37_Thumb.mp4": "/animated-bgs/4K/37_4k.mp4",
"/animated-bgs/Thumbnails/38_Thumb.mp4": "/animated-bgs/4K/38_4k.mp4",
"/animated-bgs/Thumbnails/39_Thumb.mp4": "/animated-bgs/4K/39_4k.mp4",
"/animated-bgs/Thumbnails/40_Thumb.mp4": "/animated-bgs/4K/40_4k.mp4",
"/animated-bgs/Thumbnails/41_Thumb.mp4": "/animated-bgs/4K/41_4k.mp4",
};
const togglePlayback = (index: number, type: "play" | "pause") => {

View File

@@ -3,6 +3,7 @@
import { useAutoAnimate } from "@formkit/auto-animate/react";
import * as Collapsible from "@radix-ui/react-collapsible";
import { CheckIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { UseFormReturn } from "react-hook-form";
import { cn } from "@formbricks/lib/cn";
import { TProductStyling } from "@formbricks/types/product";
@@ -33,6 +34,7 @@ export const BackgroundStylingCard = ({
isUnsplashConfigured,
form,
}: BackgroundStylingCardProps) => {
const t = useTranslations();
const [parent] = useAutoAnimate();
return (
@@ -65,12 +67,12 @@ export const BackgroundStylingCard = ({
<div className="flex flex-col">
<div className="flex items-center gap-2">
<p className={cn("font-semibold text-slate-800", isSettingsPage ? "text-sm" : "text-base")}>
Background Styling
{t("environments.surveys.edit.background_styling")}
</p>
{isSettingsPage && <Badge text="Link Surveys" type="gray" size="normal" />}
{isSettingsPage && <Badge text={t("common.link_surveys")} type="gray" size="normal" />}
</div>
<p className={cn("mt-1 text-slate-500", isSettingsPage ? "text-xs" : "text-sm")}>
Change the background to a color, image or animation.
{t("environments.surveys.edit.change_the_background_to_a_color_image_or_animation")}
</p>
</div>
</div>
@@ -84,8 +86,10 @@ export const BackgroundStylingCard = ({
render={({ field }) => (
<FormItem>
<div>
<FormLabel>Change background</FormLabel>
<FormDescription>Pick a background from our library or upload your own.</FormDescription>
<FormLabel>{t("environments.surveys.edit.change_background")}</FormLabel>
<FormDescription>
{t("environments.surveys.edit.pick_a_background_from_our_library_or_upload_your_own")}
</FormDescription>
</div>
<FormControl>
@@ -118,8 +122,10 @@ export const BackgroundStylingCard = ({
render={({ field }) => (
<FormItem>
<div>
<FormLabel>Brightness</FormLabel>
<FormDescription>Darken or lighten background of your choice.</FormDescription>
<FormLabel>{t("environments.surveys.edit.brightness")}</FormLabel>
<FormDescription>
{t("environments.surveys.edit.darken_or_lighten_background_of_your_choice")}
</FormDescription>
</div>
<FormControl>

View File

@@ -1,21 +1,23 @@
"use client";
import { LocalizedEditor } from "@/modules/ee/multi-language-surveys/components/localized-editor";
import { QuestionFormInput } from "@/modules/surveys/components/QuestionFormInput";
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { useTranslations } from "next-intl";
import { useState } from "react";
import { LocalizedEditor } from "@formbricks/ee/multi-language/components/localized-editor";
import { TAttributeClass } from "@formbricks/types/attribute-classes";
import { TSurvey, TSurveyCTAQuestion } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { Input } from "@formbricks/ui/components/Input";
import { Label } from "@formbricks/ui/components/Label";
import { OptionsSwitch } from "@formbricks/ui/components/OptionsSwitch";
import { QuestionFormInput } from "@formbricks/ui/components/QuestionFormInput";
const options = [
{
value: "internal",
label: "Button to continue in survey",
label: "environments.surveys.edit.button_to_continue_in_survey",
},
{ value: "external", label: "Button to link to external URL" },
{ value: "external", label: "environments.surveys.edit.button_to_link_to_external_url" },
];
interface CTAQuestionFormProps {
@@ -28,6 +30,7 @@ interface CTAQuestionFormProps {
setSelectedLanguageCode: (languageCode: string) => void;
isInvalid: boolean;
attributeClasses: TAttributeClass[];
locale: TUserLocale;
}
export const CTAQuestionForm = ({
@@ -40,15 +43,17 @@ export const CTAQuestionForm = ({
selectedLanguageCode,
setSelectedLanguageCode,
attributeClasses,
locale,
}: CTAQuestionFormProps): JSX.Element => {
const [firstRender, setFirstRender] = useState(true);
const t = useTranslations();
const [parent] = useAutoAnimate();
return (
<form ref={parent}>
<QuestionFormInput
id="headline"
value={question.headline}
label={"Question*"}
label={t("environments.surveys.edit.question") + "*"}
localSurvey={localSurvey}
questionIdx={questionIdx}
isInvalid={isInvalid}
@@ -56,10 +61,11 @@ export const CTAQuestionForm = ({
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
attributeClasses={attributeClasses}
locale={locale}
/>
<div className="mt-3">
<Label htmlFor="subheader">Description</Label>
<Label htmlFor="subheader">{t("common.description")}</Label>
<div className="mt-2">
<LocalizedEditor
id="subheader"
@@ -72,6 +78,7 @@ export const CTAQuestionForm = ({
firstRender={firstRender}
setFirstRender={setFirstRender}
questionIdx={questionIdx}
locale={locale}
/>
</div>
</div>
@@ -88,23 +95,24 @@ export const CTAQuestionForm = ({
<QuestionFormInput
id="buttonLabel"
value={question.buttonLabel}
label={`"Next" Button Label`}
label={t("environments.surveys.edit.next_button_label")}
localSurvey={localSurvey}
questionIdx={questionIdx}
maxLength={48}
placeholder={lastQuestion ? "Finish" : "Next"}
placeholder={lastQuestion ? t("common.finish") : t("common.next")}
isInvalid={isInvalid}
updateQuestion={updateQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
attributeClasses={attributeClasses}
locale={locale}
/>
{questionIdx !== 0 && (
<QuestionFormInput
id="backButtonLabel"
value={question.backButtonLabel}
label={`"Back" Button Label`}
label={t("environments.surveys.edit.back_button_label")}
localSurvey={localSurvey}
questionIdx={questionIdx}
maxLength={48}
@@ -114,6 +122,7 @@ export const CTAQuestionForm = ({
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
attributeClasses={attributeClasses}
locale={locale}
/>
)}
</div>
@@ -121,7 +130,7 @@ export const CTAQuestionForm = ({
{question.buttonExternal && (
<div className="mt-3 flex-1">
<Label htmlFor="buttonLabel">Button URL</Label>
<Label htmlFor="buttonLabel">{t("environments.surveys.edit.button_url")}</Label>
<div className="mt-2">
<Input
id="buttonUrl"
@@ -139,7 +148,7 @@ export const CTAQuestionForm = ({
<QuestionFormInput
id="dismissButtonLabel"
value={question.dismissButtonLabel}
label={"Skip Button Label"}
label={t("environments.surveys.edit.skip_button_label")}
localSurvey={localSurvey}
questionIdx={questionIdx}
placeholder={"skip"}
@@ -148,6 +157,7 @@ export const CTAQuestionForm = ({
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
attributeClasses={attributeClasses}
locale={locale}
/>
</div>
)}

View File

@@ -1,13 +1,15 @@
import { QuestionFormInput } from "@/modules/surveys/components/QuestionFormInput";
import { PlusIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { useEffect, useState } from "react";
import { createI18nString, extractLanguageCodes } from "@formbricks/lib/i18n/utils";
import { TAttributeClass } from "@formbricks/types/attribute-classes";
import { TSurvey, TSurveyCalQuestion } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { AdvancedOptionToggle } from "@formbricks/ui/components/AdvancedOptionToggle";
import { Button } from "@formbricks/ui/components/Button";
import { Input } from "@formbricks/ui/components/Input";
import { Label } from "@formbricks/ui/components/Label";
import { QuestionFormInput } from "@formbricks/ui/components/QuestionFormInput";
interface CalQuestionFormProps {
localSurvey: TSurvey;
@@ -19,6 +21,7 @@ interface CalQuestionFormProps {
setSelectedLanguageCode: (language: string) => void;
isInvalid: boolean;
attributeClasses: TAttributeClass[];
locale: TUserLocale;
}
export const CalQuestionForm = ({
@@ -30,10 +33,11 @@ export const CalQuestionForm = ({
setSelectedLanguageCode,
isInvalid,
attributeClasses,
locale,
}: CalQuestionFormProps): JSX.Element => {
const surveyLanguageCodes = extractLanguageCodes(localSurvey.languages);
const [isCalHostEnabled, setIsCalHostEnabled] = useState(!!question.calHost);
const t = useTranslations();
useEffect(() => {
if (!isCalHostEnabled) {
updateQuestion(questionIdx, { calHost: undefined });
@@ -49,7 +53,7 @@ export const CalQuestionForm = ({
<QuestionFormInput
id="headline"
value={question.headline}
label={"Question*"}
label={t("environments.surveys.edit.question") + "*"}
localSurvey={localSurvey}
questionIdx={questionIdx}
isInvalid={isInvalid}
@@ -57,6 +61,7 @@ export const CalQuestionForm = ({
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
attributeClasses={attributeClasses}
locale={locale}
/>
<div>
{question.subheader !== undefined && (
@@ -65,7 +70,7 @@ export const CalQuestionForm = ({
<QuestionFormInput
id="subheader"
value={question.subheader}
label={"Description"}
label={t("common.description")}
localSurvey={localSurvey}
questionIdx={questionIdx}
isInvalid={isInvalid}
@@ -73,6 +78,7 @@ export const CalQuestionForm = ({
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
attributeClasses={attributeClasses}
locale={locale}
/>
</div>
</div>
@@ -90,12 +96,12 @@ export const CalQuestionForm = ({
}}>
{" "}
<PlusIcon className="mr-1 h-4 w-4" />
Add Description
{t("environments.surveys.edit.add_description")}
</Button>
)}
<div className="mt-3 flex flex-col gap-6">
<div className="flex flex-col gap-3">
<Label htmlFor="calUserName">Cal.com username or username/event</Label>
<Label htmlFor="calUserName">{t("environments.surveys.edit.cal_username")}</Label>
<div>
<Input
id="calUserName"
@@ -110,13 +116,13 @@ export const CalQuestionForm = ({
isChecked={isCalHostEnabled}
onToggle={(checked: boolean) => setIsCalHostEnabled(checked)}
htmlId="calHost"
description="Needed for a self-hosted Cal.com instance"
description={t("environments.surveys.edit.needed_for_self_hosted_cal_com_instance")}
childBorder
title="Custom hostname"
title={t("environments.surveys.edit.custom_hostname")}
customContainerClass="p-0">
<div className="p-4">
<div className="flex items-center gap-2">
<Label htmlFor="calHost">Hostname</Label>
<Label htmlFor="calHost">{t("environments.surveys.edit.hostname")}</Label>
<Input
id="calHost"
name="calHost"

View File

@@ -3,6 +3,7 @@
import { useAutoAnimate } from "@formkit/auto-animate/react";
import * as Collapsible from "@radix-ui/react-collapsible";
import { CheckIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import React from "react";
import { UseFormReturn } from "react-hook-form";
import { cn } from "@formbricks/lib/cn";
@@ -35,6 +36,7 @@ export const CardStylingSettings = ({
setOpen,
form,
}: CardStylingSettingsProps) => {
const t = useTranslations();
const isAppSurvey = surveyType === "app";
const surveyTypeDerived = isAppSurvey ? "App" : "Link";
const isLogoVisible = !!product.logo?.url;
@@ -71,10 +73,10 @@ export const CardStylingSettings = ({
<div>
<p className={cn("font-semibold text-slate-800", isSettingsPage ? "text-sm" : "text-base")}>
Card Styling
{t("environments.surveys.edit.card_styling")}
</p>
<p className={cn("mt-1 text-slate-500", isSettingsPage ? "text-xs" : "text-sm")}>
Style the survey card.
{t("environments.surveys.edit.style_the_survey_card")}
</p>
</div>
</div>
@@ -91,8 +93,10 @@ export const CardStylingSettings = ({
render={() => (
<FormItem>
<div>
<FormLabel>Roundness</FormLabel>
<FormDescription>Change the border radius of the card and the inputs.</FormDescription>
<FormLabel>{t("environments.surveys.edit.roundness")}</FormLabel>
<FormDescription>
{t("environments.surveys.edit.change_the_border_radius_of_the_card_and_the_inputs")}
</FormDescription>
</div>
<FormControl>
@@ -117,8 +121,10 @@ export const CardStylingSettings = ({
render={({ field }) => (
<FormItem className="space-y-4">
<div>
<FormLabel>Card background color</FormLabel>
<FormDescription>Change the background color of the card.</FormDescription>
<FormLabel>{t("environments.surveys.edit.card_background_color")}</FormLabel>
<FormDescription>
{t("environments.surveys.edit.change_the_background_color_of_the_card")}
</FormDescription>
</div>
<FormControl>
@@ -138,8 +144,10 @@ export const CardStylingSettings = ({
render={({ field }) => (
<FormItem className="space-y-4">
<div>
<FormLabel>Card border color</FormLabel>
<FormDescription>Change the border color of the card.</FormDescription>
<FormLabel>{t("environments.surveys.edit.card_border_color")}</FormLabel>
<FormDescription>
{t("environments.surveys.edit.change_the_border_color_of_the_card")}
</FormDescription>
</div>
<FormControl>
@@ -159,8 +167,10 @@ export const CardStylingSettings = ({
render={({ field }) => (
<FormItem className="space-y-4">
<div>
<FormLabel>Card shadow color</FormLabel>
<FormDescription>Change the shadow color of the card.</FormDescription>
<FormLabel>{t("environments.surveys.edit.card_shadow_color")}</FormLabel>
<FormDescription>
{t("environments.surveys.edit.change_the_shadow_color_of_the_card")}
</FormDescription>
</div>
<FormControl>
@@ -180,10 +190,18 @@ export const CardStylingSettings = ({
render={() => (
<FormItem>
<div>
<FormLabel>Card Arrangement for {surveyTypeDerived} Surveys</FormLabel>
<FormLabel>
{t("environments.surveys.edit.card_arrangement_for_survey_type_derived", {
surveyTypeDerived: surveyTypeDerived,
})}
</FormLabel>
<FormDescription>
How funky do you want your cards in {surveyTypeDerived} Surveys
{t(
"environments.surveys.edit.how_funky_do_you_want_your_cards_in_survey_type_derived_surveys",
{
surveyTypeDerived: surveyTypeDerived,
}
)}
</FormDescription>
</div>
<FormControl>
@@ -216,8 +234,10 @@ export const CardStylingSettings = ({
</FormControl>
<div>
<FormLabel>Hide progress bar</FormLabel>
<FormDescription>Disable the visibility of survey progress.</FormDescription>
<FormLabel>{t("environments.surveys.edit.hide_progress_bar")}</FormLabel>
<FormDescription>
{t("environments.surveys.edit.disable_the_visibility_of_survey_progress")}
</FormDescription>
</div>
</FormItem>
)}
@@ -241,10 +261,12 @@ export const CardStylingSettings = ({
<div>
<FormLabel>
Hide logo
<Badge text="Link Surveys" type="gray" size="normal" />
{t("environments.surveys.edit.hide_logo")}
<Badge text={t("common.link_surveys")} type="gray" size="normal" />
</FormLabel>
<FormDescription>Hides the logo in this specific survey</FormDescription>
<FormDescription>
{t("environments.surveys.edit.hide_the_logo_in_this_specific_survey")}
</FormDescription>
</div>
</FormItem>
)}
@@ -279,9 +301,9 @@ export const CardStylingSettings = ({
</FormControl>
<div>
<FormLabel>Add highlight border</FormLabel>
<FormLabel>{t("environments.surveys.edit.add_highlight_border")}</FormLabel>
<FormDescription className="text-xs font-normal text-slate-500">
Add an outer border to your survey card.
{t("environments.surveys.edit.add_highlight_border_description")}
</FormDescription>
</div>
</div>

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