Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a4bbfbd1c | ||
|
|
9f63822038 | ||
|
|
9756aed94f | ||
|
|
97562118a1 | ||
|
|
fd217308e1 | ||
|
|
6d4098b8b8 | ||
|
|
2f7b70516c | ||
|
|
9761483530 | ||
|
|
6cea8a2246 | ||
|
|
b7250a284a | ||
|
|
1402f4a48b | ||
|
|
70fe0fb7a7 | ||
|
|
5471324cfe | ||
|
|
0a78848612 | ||
|
|
f553bce7f1 | ||
|
|
43566a54b6 | ||
|
|
51a811ac8e | ||
|
|
74fba30d5f | ||
|
|
22ea797b15 | ||
|
|
0ce10e8824 | ||
|
|
ca0d63a0fc | ||
|
|
2a9b34104d | ||
|
|
16cbc3365b | ||
|
|
0122ccb797 | ||
|
|
fc2beb3d19 | ||
|
|
698da4c3a1 | ||
|
|
07f6f1d04b |
@@ -12,8 +12,8 @@
|
||||
// Configure properties specific to VS Code.
|
||||
"vscode": {
|
||||
// Add the IDs of extensions you want installed when the container is created.
|
||||
"extensions": ["dbaeumer.vscode-eslint"]
|
||||
}
|
||||
"extensions": ["dbaeumer.vscode-eslint"],
|
||||
},
|
||||
},
|
||||
|
||||
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
||||
@@ -25,5 +25,5 @@
|
||||
"postAttachCommand": "pnpm dev --filter=web... --filter=demo...",
|
||||
|
||||
// Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root.
|
||||
"remoteUser": "node"
|
||||
"remoteUser": "node",
|
||||
}
|
||||
|
||||
@@ -137,3 +137,7 @@ ENTERPRISE_LICENSE_KEY=
|
||||
|
||||
# set to 1 to skip onboarding for new users
|
||||
# ONBOARDING_DISABLED=1
|
||||
|
||||
# Send new users to customer.io
|
||||
# CUSTOMER_IO_API_KEY=
|
||||
# CUSTOMER_IO_SITE_ID=
|
||||
|
||||
5
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
@@ -42,7 +42,6 @@ body:
|
||||
- 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. Use our [Formbricks ✨ megaprompt ✨](https://github.com/formbricks/formbricks/blob/main/megaprompt.md) to create the right
|
||||
context before you write your prompt.
|
||||
- Everything is type-safe.
|
||||
- We use **chatGPT** to help refactor code.
|
||||
- Anything unclear? [Ask in Discord](https://formbricks.com/discord)
|
||||
|
||||
1
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -32,7 +32,6 @@ Fixes # (issue)
|
||||
- [ ] Removed all `console.logs`
|
||||
- [ ] Merged the latest changes from main onto my branch with `git pull origin main`
|
||||
- [ ] My changes don't cause any responsiveness issues
|
||||
- [ ] First PR at Formbricks? [Please sign the CLA!](https://formbricks.com/clmyhzfrymr4ko00hycsg1tvx) Without it we wont be able to merge it 🙏
|
||||
|
||||
### Appreciated
|
||||
|
||||
|
||||
6
.github/workflows/build-formbricks-com.yml
vendored
@@ -1,4 +1,4 @@
|
||||
name: Build
|
||||
name: Build formbricks-com
|
||||
on:
|
||||
workflow_call:
|
||||
jobs:
|
||||
@@ -11,10 +11,10 @@ jobs:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Setup Node.js 18.x
|
||||
- name: Setup Node.js 20.x
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18.x
|
||||
node-version: 20.x
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
|
||||
2
.github/workflows/build-web.yml
vendored
@@ -1,4 +1,4 @@
|
||||
name: Build
|
||||
name: Build web
|
||||
on:
|
||||
workflow_call:
|
||||
jobs:
|
||||
|
||||
137
.github/workflows/ecs-deployment.yml
vendored
Normal file
@@ -0,0 +1,137 @@
|
||||
name: ECS
|
||||
|
||||
# This workflow uses actions that are not certified by GitHub.
|
||||
# They are provided by a third-party and are governed by
|
||||
# separate terms of service, privacy policy, and support
|
||||
# documentation.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch: # Add manual trigger support
|
||||
|
||||
env:
|
||||
# Use docker.io for Docker Hub if empty
|
||||
REGISTRY: ghcr.io
|
||||
# github.repository as <account>/<repo>
|
||||
IMAGE_NAME: formbricks/formbricks-experimental
|
||||
DATABASE_URL: "postgresql://postgres:postgres@localhost:5432/formbricks?schema=public"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
# This is used to complete the identity challenge
|
||||
# with sigstore/fulcio when running outside of PRs.
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: Generate Random NEXTAUTH_SECRET
|
||||
run: |
|
||||
SECRET=$(openssl rand -hex 32)
|
||||
echo "NEXTAUTH_SECRET=$SECRET" >> $GITHUB_ENV
|
||||
|
||||
- name: Generate Random ENCRYPTION_KEY
|
||||
run: |
|
||||
SECRET=$(openssl rand -hex 32)
|
||||
echo "ENCRYPTION_KEY=$SECRET" >> $GITHUB_ENV
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Depot CLI
|
||||
uses: depot/setup-action@v1
|
||||
|
||||
# https://github.com/sigstore/cosign-installer
|
||||
- name: Install cosign
|
||||
uses: sigstore/cosign-installer@6e04d228eb30da1757ee4e1dd75a0ec73a653e06 #v3.1.1
|
||||
with:
|
||||
cosign-release: "v2.1.1"
|
||||
|
||||
# https://github.com/docker/login-action
|
||||
- name: Log into registry
|
||||
uses: docker/login-action@v3 # v3.0.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Extract metadata (tags, labels) for Docker
|
||||
# https://github.com/docker/metadata-action
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5 # v5.0.0
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=sha,format=long
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
# Build and push Docker image with Buildx
|
||||
# https://github.com/docker/build-push-action
|
||||
- name: Build and push Docker image
|
||||
id: build-and-push
|
||||
uses: depot/build-push-action@v1
|
||||
env:
|
||||
NEXT_PUBLIC_SENTRY_DSN: ${{ secrets.NEXT_PUBLIC_SENTRY_DSN }}
|
||||
with:
|
||||
project: tw0fqmsx3c
|
||||
token: ${{ secrets.DEPOT_PROJECT_TOKEN }}
|
||||
context: .
|
||||
file: ./apps/web/Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
build-args: |
|
||||
NEXTAUTH_SECRET=${{ env.NEXTAUTH_SECRET }}
|
||||
DATABASE_URL=${{ env.DATABASE_URL }}
|
||||
ENCRYPTION_KEY=${{ env.ENCRYPTION_KEY }}
|
||||
NEXT_PUBLIC_SENTRY_DSN=${{ env.NEXT_PUBLIC_SENTRY_DSN }}
|
||||
|
||||
- name: Sign the images with GitHub OIDC Token
|
||||
env:
|
||||
DIGEST: ${{ steps.build-and-push.outputs.digest }}
|
||||
TAGS: ${{ steps.meta.outputs.tags }}
|
||||
run: |
|
||||
images=""
|
||||
for tag in ${TAGS}; do
|
||||
images+="${tag}@${DIGEST} "
|
||||
done
|
||||
cosign sign --yes ${images}
|
||||
|
||||
deploy:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Configure AWS credentials
|
||||
uses: aws-actions/configure-aws-credentials@v1
|
||||
with:
|
||||
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
aws-region: ${{ secrets.AWS_REGION }}
|
||||
|
||||
- name: Download task definition
|
||||
run: |
|
||||
aws ecs describe-task-definition --task-definition prod-webapp-ecs-service --query taskDefinition > task-definition.json
|
||||
|
||||
- name: Fill in the new image ID in the Amazon ECS task definition
|
||||
id: task-def
|
||||
uses: aws-actions/amazon-ecs-render-task-definition@v1
|
||||
with:
|
||||
task-definition: task-definition.json
|
||||
container-name: prod-webapp-container
|
||||
image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
||||
|
||||
- name: Deploy Amazon ECS task definition
|
||||
uses: aws-actions/amazon-ecs-deploy-task-definition@v1
|
||||
with:
|
||||
task-definition: ${{ steps.task-def.outputs.task-definition }}
|
||||
service: prod-webapp-ecs-service
|
||||
cluster: prod-core-infra-ecs-cluster
|
||||
wait-for-service-stability: true
|
||||
@@ -126,8 +126,6 @@ Formbricks has a hosted cloud offering with a generous free plan to get you up a
|
||||
|
||||
Formbricks is available Open-Source under AGPLv3 license. You can host Formbricks on your own servers using Docker without a subscription.
|
||||
|
||||
(In the future we may develop additional features that aren't in the free Open-Source version).
|
||||
|
||||
If you opt for self-hosting Formbricks, here are a few options to consider:
|
||||
|
||||
#### Docker
|
||||
|
||||
@@ -46,7 +46,8 @@ To run the Churn Survey in your app you want to proceed as follows:
|
||||
4. Prevent that churn!
|
||||
|
||||
<Note>
|
||||
## Formbricks Widget running? We assume that you have already installed the Formbricks Widget in your web
|
||||
## Formbricks Widget running?
|
||||
We assume that you have already installed the Formbricks Widget in your web
|
||||
app. It’s required to display messages and surveys in your app. If not, please follow the [Quick Start Guide
|
||||
(takes 15mins max.)](/docs/getting-started/quickstart-in-app-survey)
|
||||
</Note>
|
||||
@@ -119,7 +120,8 @@ Whenever a user visits this page, matches the filter conditions above and the re
|
||||
Here is our complete [Actions manual](/docs/actions/why) covering [Code](/docs/actions/code) and [No-Code](/docs/actions/no-code) Actions.
|
||||
|
||||
<Note>
|
||||
## Pre-churn flow coming soon We’re currently building full-screen survey pop-ups. You’ll be able to prevent
|
||||
## Pre-churn flow coming soon
|
||||
We’re currently building full-screen survey pop-ups. You’ll be able to prevent
|
||||
users from closing the survey unless they respond to it. It’s certainly debatable if you want that but you
|
||||
could force them to click through the survey before letting them cancel 🤷
|
||||
</Note>
|
||||
@@ -156,7 +158,8 @@ These settings make sure the survey is always displayed, when a user wants to Ca
|
||||
/>
|
||||
|
||||
<Note>
|
||||
## Formbricks Widget running? You need to have the Formbricks Widget installed to display the Churn Survey
|
||||
## Formbricks Widget running?
|
||||
You need to have the Formbricks Widget installed to display the Churn Survey
|
||||
in your app. Please follow [this tutorial (Step 4 onwards)](/docs/getting-started/quickstart-in-app-survey)
|
||||
to install the widget.
|
||||
</Note>
|
||||
|
||||
@@ -43,7 +43,8 @@ To run the Feature Chaser survey in your app you want to proceed as follows:
|
||||
2. Setup a user action to display survey at the right point in time
|
||||
|
||||
<Note>
|
||||
## Formbricks Widget running? We assume that you have already installed the Formbricks Widget in your web
|
||||
## Formbricks Widget running?
|
||||
We assume that you have already installed the Formbricks Widget in your web
|
||||
app. It’s required to display messages and surveys in your app. If not, please follow the [Quick Start Guide
|
||||
(takes 15mins max.)](/docs/getting-started/quickstart-in-app-survey)
|
||||
</Note>
|
||||
@@ -127,7 +128,8 @@ Lastly, scroll down to “Recontact Options”. Here you have full freedom to de
|
||||
<Image src={Publish} alt="Publish survey" quality="100" className="max-w-full rounded-lg sm:max-w-3xl" />
|
||||
|
||||
<Note>
|
||||
## Formbricks Widget running? You need to have the Formbricks Widget installed to display the Feature Chaser
|
||||
## Formbricks Widget running?
|
||||
You need to have the Formbricks Widget installed to display the Feature Chaser
|
||||
in your app. Please follow [this tutorial (Step 4 onwards)](/docs/getting-started/quickstart-in-app-survey)
|
||||
to install the widget.
|
||||
</Note>
|
||||
|
||||
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 9.9 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 45 KiB |
@@ -0,0 +1,113 @@
|
||||
import DemoPreview from "@/components/dummyUI/DemoPreview";
|
||||
import Image from "next/image";
|
||||
import NewsletterSurveyType from './choose-survey-type.webp';
|
||||
import NewsletterSurveyEmbedCode from './embed-survey-code-in-your-email.webp';
|
||||
import NewsletterSurveyEmbedPrompt from './embed-survey-prompt.webp';
|
||||
import NewsletterSurveyEditor from './improve-newsletter-content-editor-formbricks.webp';
|
||||
import NewsletterSurvey from './improve-newsletter-content-survey-location.webp';
|
||||
export const metadata = {
|
||||
title: "Measure email content quality with Formbricks",
|
||||
description:
|
||||
"Measuring the content quality of both transactional and marketing email is a key element for improving customer communication.",
|
||||
};
|
||||
|
||||
#### Best Practices
|
||||
|
||||
# Improve Email Content
|
||||
Email remains the predominant way to communicate with your customers. Measure the effectiveness to improve your offering.
|
||||
|
||||
|
||||
## Purpose
|
||||
|
||||
Measuring the content quality of both transactional and marketing email is a key element for improving customer communication.
|
||||
|
||||
## Preview
|
||||
|
||||
<DemoPreview template="Improve Newsletter Content" />
|
||||
|
||||
## Formbricks Approach
|
||||
|
||||
- Embed the survey into your email so it’s part of the newsletter.
|
||||
- Use link prefilling to store the answer users clicked on in the email.
|
||||
- Dynamic user identification to append reader's email for personalized profiles and follow ups.
|
||||
|
||||
## Installation
|
||||
|
||||
To embed the newsletter survey into your email, follow these steps:
|
||||
|
||||
1. Create new 'Improve Newsletter Content' survey at [app.formbricks.com](https://app.formbricks.com/)
|
||||
2. Select how you where you want to display the survey.
|
||||
3. Copy the embed code anywhere you want in your newsletter.
|
||||
|
||||
### 1. Create new 'Improve Newsletter Content' Survey
|
||||
|
||||
If you don't have an account yet, create one at [app.formbricks.com](https://app.formbricks.com/auth/signup)
|
||||
|
||||
Then, create a new survey and look for the "Improve Newsletter Content" template:
|
||||
|
||||
<Image
|
||||
src={NewsletterSurvey}
|
||||
alt="Create Improve Newsletter Content by template"
|
||||
quality="100"
|
||||
className="rounded-lg max-w-full sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
### 2. Customize Survey questions
|
||||
|
||||
Customize survey questions, emojis or stars however you like:
|
||||
|
||||
<Image
|
||||
src={NewsletterSurveyEditor}
|
||||
alt="Edit Improve Newsletter Content template"
|
||||
quality="100"
|
||||
className="rounded-lg max-w-full sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
### 3. Configure Survey Settings
|
||||
|
||||
When you are done customizing your survey questions, navigate to the Settings tab and choose the type of survey you want. You need to choose Link Survey:
|
||||
|
||||
<Image
|
||||
src={NewsletterSurveyType}
|
||||
alt="Choose survey type"
|
||||
quality="100"
|
||||
className="rounded-lg max-w-full sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
### 4. Choose how you want to embed your survey
|
||||
|
||||
After publishing your survey, a modal that prompts you to embed your survey will pop up.
|
||||
|
||||
<Image
|
||||
src={NewsletterSurveyEmbedPrompt}
|
||||
alt="Embed newsletter survey"
|
||||
quality="100"
|
||||
className="rounded-lg max-w-full sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
Select the Embed Survey card and you will be directed to another modal, where the first embed option displayed will be to embed the survey in an email.
|
||||
|
||||
### 5. Copy code to embed the survey in your newsletter
|
||||
|
||||
Click the button with the “View Embed Code” text at the top right corner of the modal and simply paste the HTML code for your survey anywhere you want it in your newsletter. You can see the preview in the below image:
|
||||
|
||||
<Image
|
||||
src={NewsletterSurveyEmbedCode}
|
||||
alt="Embed survey code"
|
||||
quality="100"
|
||||
className="rounded-lg max-w-full sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
And you're done! Send a test email to yourself and try it out 🤓
|
||||
|
||||
|
||||
## Learn about data prefilling
|
||||
|
||||
<Note>
|
||||
## How does data prefilling work?
|
||||
Learn about how link prefilling and user identification maximize your insights in [this detailed guide](/blog/how-smart-writers-use-formbricks-open-source-tool-to-measure-the-quality-of-their-newsletter-content).
|
||||
</Note>
|
||||
|
||||
###
|
||||
|
||||
# That’s it! 🎉
|
||||
@@ -43,7 +43,8 @@ To display the Trial Conversion Survey in your app you want to proceed as follow
|
||||
3. Print that 💸
|
||||
|
||||
<Note>
|
||||
## Formbricks Widget running? We assume that you have already installed the Formbricks Widget in your web
|
||||
## Formbricks Widget running?
|
||||
We assume that you have already installed the Formbricks Widget in your web
|
||||
app. It’s required to display messages and surveys in your app. If not, please follow the [Quick Start Guide
|
||||
(takes 15mins max.)](/docs/getting-started/quickstart-in-app-survey)
|
||||
</Note>
|
||||
@@ -79,7 +80,8 @@ Save, and move over to the “Audience” tab.
|
||||
### 3. Pre-segment your audience (coming soon)
|
||||
|
||||
<Note>
|
||||
## Filter by attribute coming soon We're working on pre-segmenting users by attributes. We will update this
|
||||
## Filter by attribute coming soon
|
||||
We're working on pre-segmenting users by attributes. We will update this
|
||||
manual in the next days.
|
||||
</Note>
|
||||
|
||||
@@ -136,7 +138,8 @@ Lastly, scroll down to “Recontact Options”. Here you have to choose the corr
|
||||
<Image src={Publish} alt="Publish survey" quality="100" className="max-w-full rounded-lg sm:max-w-3xl" />
|
||||
|
||||
<Note>
|
||||
## Formbricks Widget running? You need to have the Formbricks Widget installed to display the Feedback Box
|
||||
## Formbricks Widget running?
|
||||
You need to have the Formbricks Widget installed to display the Feedback Box
|
||||
in your app. Please follow [this tutorial (Step 4 onwards)](/docs/getting-started/quickstart-in-app-survey)
|
||||
to install the widget.
|
||||
</Note>
|
||||
|
||||
@@ -48,7 +48,8 @@ To display an Interview Prompt in your app you want to proceed as follows:
|
||||
3. That’s it! 🎉
|
||||
|
||||
<Note>
|
||||
## Formbricks Widget running? We assume that you have already installed the Formbricks Widget in your web
|
||||
## Formbricks Widget running?
|
||||
We assume that you have already installed the Formbricks Widget in your web
|
||||
app. It’s required to display messages and surveys in your app. If not, please follow the [Quick Start Guide
|
||||
(15mins).](/docs/getting-started/quickstart-in-app-survey)
|
||||
</Note>
|
||||
@@ -91,7 +92,8 @@ Save, and move over to the “Audience” tab.
|
||||
### 3. Pre-segment your audience (coming soon)
|
||||
|
||||
<Note>
|
||||
## Filter by attribute coming soon We're working on pre-segmenting users by attributes. We will update this
|
||||
## Filter by attribute coming soon
|
||||
We're working on pre-segmenting users by attributes. We will update this
|
||||
manual in the next few days.
|
||||
</Note>
|
||||
|
||||
@@ -108,7 +110,8 @@ To create the trigger to show your Interview Prompt, go to the “Audience” ta
|
||||
<Image src={AddAction} alt="Add action" quality="100" className="max-w-full rounded-lg sm:max-w-3xl" />
|
||||
|
||||
<Note>
|
||||
## You can also add actions in your code You can also create [Code Actions](/docs/actions/code) using
|
||||
## You can also add actions in your code
|
||||
You can also create [Code Actions](/docs/actions/code) using
|
||||
`formbricks.track("Eventname")` - they will automatically appear in your Actions overview as long as the SDK
|
||||
is embedded.
|
||||
</Note>
|
||||
@@ -161,7 +164,8 @@ Scroll down to “Recontact Options”. Here you have to choose the correct sett
|
||||
<Image src={Publish} alt="Publish survey" quality="100" className="max-w-full rounded-lg sm:max-w-3xl" />
|
||||
|
||||
<Note>
|
||||
## Formbricks Widget running? You need to have the Formbricks Widget installed to display the Feedback Box
|
||||
## Formbricks Widget running?
|
||||
You need to have the Formbricks Widget installed to display the Feedback Box
|
||||
in your app. Please follow [this tutorial (Step 4 onwards)](/docs/getting-started/quickstart-in-app-survey)
|
||||
to install the widget.
|
||||
</Note>
|
||||
|
||||
@@ -42,7 +42,8 @@ To display the Product-Market Fit survey in your app you want to proceed as foll
|
||||
3. Setup the user action to display survey at good point in time
|
||||
|
||||
<Note>
|
||||
## Formbricks Widget running? We assume that you have already installed the Formbricks Widget in your web
|
||||
## Formbricks Widget running?
|
||||
We assume that you have already installed the Formbricks Widget in your web
|
||||
app. It’s required to display messages and surveys in your app. If not, please follow the [Quick Start Guide
|
||||
(15mins).](/docs/getting-started/quickstart-in-app-survey)
|
||||
</Note>
|
||||
@@ -78,7 +79,8 @@ Save, and move over to where the magic happens: The “Audience” tab.
|
||||
### 3. Pre-segment your audience (coming soon)
|
||||
|
||||
<Note>
|
||||
## Filter by attribute coming soon We're working on pre-segmenting users by attributes. We will update this
|
||||
## Filter by attribute coming soon
|
||||
We're working on pre-segmenting users by attributes. We will update this
|
||||
manual in the next days.
|
||||
</Note>
|
||||
|
||||
@@ -139,7 +141,8 @@ Lastly, scroll down to “Recontact Options”. Here you have to choose the corr
|
||||
<Image src={Publish} alt="Publish survey" quality="100" className="max-w-full rounded-lg sm:max-w-3xl" />
|
||||
|
||||
<Note>
|
||||
## Formbricks Widget running? You need to have the Formbricks Widget installed to display the Feedback Box
|
||||
## Formbricks Widget running?
|
||||
You need to have the Formbricks Widget installed to display the Feedback Box
|
||||
in your app. Please follow [this tutorial (Step 4 onwards)](/docs/getting-started/quickstart-in-app-survey)
|
||||
to install the widget.
|
||||
</Note>
|
||||
|
||||
@@ -42,6 +42,4 @@ If you are at all unsure, just raise it as an enhancement issue first and tell u
|
||||
|
||||
To be able to keep working on Formbricks over the coming years, we need to collect a CLA from all relevant contributors.
|
||||
|
||||
Please note that we can only get your contribution merged when we have a CLA signed by you.
|
||||
|
||||
To access the CLA form, please click [here](https://formbricks.com/clmyhzfrymr4ko00hycsg1tvx)
|
||||
Once you open a PR, you will get a message from the CLA bot to fill out the form. Please note that we can only get your contribution merged when we have a CLA signed by you.
|
||||
|
||||
|
After Width: | Height: | Size: 49 KiB |
@@ -0,0 +1,88 @@
|
||||
import Image from "next/image";
|
||||
|
||||
import HomePage from "./home-page.webp";
|
||||
import SurveyQuestions from "./survey-questions.webp";
|
||||
import SurveySettings from "./survey-settings.webp";
|
||||
import SurveyResponseOptions from "./survey-response-options.webp";
|
||||
import SurveyPublished from "./survey-published.webp";
|
||||
|
||||
export const metadata = {
|
||||
title: "Formbricks Quickstart Guide: Link Surveys Made Easier & Faster",
|
||||
description:
|
||||
"Formbricks is the easiest way to create and manage link surveys. This quickstart guide will show you how to create your first link survey in under 5 minutes.",
|
||||
};
|
||||
|
||||
#### Getting Started
|
||||
|
||||
# Quickstart
|
||||
|
||||
Link Surveys make it easy for your users to give you feedback. They are a great way to get feedback from your users, without interrupting their workflow. This quickstart guide will show you how to create your first link survey in under 5 minutes.
|
||||
|
||||
## Create a free Formbricks Cloud account
|
||||
|
||||
While you can [self-host](/docs/self-hosting/deployment) Formbricks, but the quickest and easiest way to get started is with the free Cloud plan. Just [sign up here](https://app.formbricks.com/auth/signup) and click through the onboarding, until you’re here:
|
||||
|
||||
<Image
|
||||
src={HomePage}
|
||||
alt="Choose a link survey template"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl "
|
||||
/>
|
||||
|
||||
Choose one of the pre-created templates to get started. We’ll choose the **Product Market Fit** template for this quickstart guide.
|
||||
|
||||
## Create your first survey
|
||||
|
||||
On clicking the template, you’ll be forwarded to the survey editor. Here you can edit the survey questions and settings. For the sake of simplicity, we'll keep the questions as they are and move to the survey settings.
|
||||
|
||||
<Image
|
||||
src={SurveyQuestions}
|
||||
alt="Survey Editor opens up in the Formbricks App"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
Click on the **Settings** tab to edit the survey settings.
|
||||
|
||||
## Configure your survey settings
|
||||
|
||||
Formbricks packs a lot of useful functionality out of the box. You can:
|
||||
|
||||
- Close the survey on a specidic date
|
||||
- After a number of response
|
||||
- Redirect users to a URL after they completed the survey
|
||||
- Protect survey with a Pin
|
||||
- ... and much more!
|
||||
|
||||
<Image
|
||||
src={SurveyResponseOptions}
|
||||
alt="Survey response configuration for link survey"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
## Style your survey
|
||||
|
||||
Style your survey to your need. You can keep it simplistic or use animated backgrounds. You can change the main color and soon you'll be able to fully control the appearance of the survey.
|
||||
|
||||
<Image
|
||||
src={SurveySettings}
|
||||
alt="UI & View configuration for link survey"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
## Publish your survey
|
||||
|
||||
Once you’re happy with the survey settings, hit **Publish** and you’ll be forwarded to the Summary Page. This is where you’ll find the responses to this survey.
|
||||
|
||||
<Image
|
||||
src={SurveyPublished}
|
||||
alt="Survey published successfully and received link to share with users"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
## Share your survey
|
||||
|
||||
Congratulations! Your survey is now published and ready to be shared with your users. You can share the survey link via email, SMS, or any other channel.
|
||||
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 32 KiB |
@@ -5,7 +5,7 @@ import { useRouter } from "next/router";
|
||||
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
|
||||
import { FooterLogo } from "../../components/shared/Logo";
|
||||
import { FooterLogo } from "../shared/Logo";
|
||||
|
||||
export default function HeaderLight() {
|
||||
const plausible = usePlausible();
|
||||
@@ -1,5 +1,5 @@
|
||||
import Footer from "../../components/shared/Footer";
|
||||
import MetaInformation from "../../components/shared/MetaInformation";
|
||||
import Footer from "../shared/Footer";
|
||||
import MetaInformation from "../shared/MetaInformation";
|
||||
import HeaderLight from "./HeaderLight";
|
||||
|
||||
interface LayoutProps {
|
||||
@@ -202,6 +202,7 @@ export const navigation: Array<NavGroup> = [
|
||||
{
|
||||
title: "Link Surveys",
|
||||
links: [
|
||||
{ title: "Quickstart", href: "/docs/link-surveys/quickstart" },
|
||||
{ title: "Data Prefilling", href: "/docs/link-surveys/data-prefilling" },
|
||||
{ title: "Identify Users", href: "/docs/link-surveys/user-identification" },
|
||||
{ title: "Single Use Links", href: "/docs/link-surveys/single-use-links" },
|
||||
@@ -218,6 +219,7 @@ export const navigation: Array<NavGroup> = [
|
||||
{ title: "Feature Chaser", href: "/docs/best-practices/feature-chaser" },
|
||||
{ title: "Feedback Box", href: "/docs/best-practices/feedback-box" },
|
||||
{ title: "Docs Feedback", href: "/docs/best-practices/docs-feedback" },
|
||||
{ title: "Improve Email Content", href: "/docs/best-practices/improve-email-content" },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -53,12 +53,11 @@ export default function TemplateList({ onTemplateClick, activeTemplate }: Templa
|
||||
{templates
|
||||
.filter((template) => selectedFilter === ALL_CATEGORY_NAME || template.category === selectedFilter)
|
||||
.map((template: TTemplate) => (
|
||||
<button
|
||||
type="button"
|
||||
<div
|
||||
key={template.name}
|
||||
onClick={() => {
|
||||
onTemplateClick(template); // Pass the 'template' object instead of 'activeTemplate'
|
||||
}}
|
||||
key={template.name}
|
||||
className={cn(
|
||||
activeTemplate?.name === template.name && "ring-brand ring-2",
|
||||
"duration-120 group relative rounded-lg bg-white p-6 shadow transition-all duration-150 hover:scale-105 dark:bg-slate-700"
|
||||
@@ -71,7 +70,7 @@ export default function TemplateList({ onTemplateClick, activeTemplate }: Templa
|
||||
{template.name}
|
||||
</h3>
|
||||
<p className="text-left text-xs text-slate-600 dark:text-slate-400">{template.description}</p>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
DashboardIcon,
|
||||
DogChaserIcon,
|
||||
DoorIcon,
|
||||
EmailIcon,
|
||||
FeedbackIcon,
|
||||
GaugeSpeedFastIcon,
|
||||
HeartCommentIcon,
|
||||
@@ -1224,6 +1225,61 @@ export const templates: TTemplate[] = [
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Improve Newsletter Content",
|
||||
icon: EmailIcon,
|
||||
category: "Growth",
|
||||
description: "Find out how your subscribers like your newsletter content.",
|
||||
objectives: ["increase_conversion", "sharpen_marketing_messaging"],
|
||||
preset: {
|
||||
name: "Improve Newsletter Content",
|
||||
questions: [
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionType.Rating,
|
||||
logic: [
|
||||
{ value: "5", condition: "equals", destination: "l2q1chqssong8n0xwaagyl8g" },
|
||||
{ value: "5", condition: "lessThan", destination: "k3s6gm5ivkc5crpycdbpzkpa" },
|
||||
],
|
||||
range: 5,
|
||||
scale: "smiley",
|
||||
headline: "How would you rate this weeks newsletter?",
|
||||
required: true,
|
||||
subheader: "",
|
||||
lowerLabel: "Meh",
|
||||
upperLabel: "Great",
|
||||
},
|
||||
{
|
||||
id: "k3s6gm5ivkc5crpycdbpzkpa",
|
||||
type: TSurveyQuestionType.OpenText,
|
||||
logic: [
|
||||
{ condition: "submitted", destination: "end" },
|
||||
{ condition: "skipped", destination: "end" },
|
||||
],
|
||||
headline: "What would have made this weeks newsletter more helpful?",
|
||||
required: false,
|
||||
placeholder: "Type your answer here...",
|
||||
inputType: "text",
|
||||
},
|
||||
{
|
||||
id: "l2q1chqssong8n0xwaagyl8g",
|
||||
html: '<p class="fb-editor-paragraph" dir="ltr"><span>Who thinks like you? You\'d do us a huge favor if you\'d share this weeks episode with your brain friend!</span></p>',
|
||||
type: TSurveyQuestionType.CTA,
|
||||
headline: "Thanks! ❤️ Spread the love with ONE friend.",
|
||||
required: false,
|
||||
buttonUrl: "https://formbricks.com",
|
||||
buttonLabel: "Happy to help!",
|
||||
buttonExternal: true,
|
||||
dismissButtonLabel: "Find your own friends",
|
||||
},
|
||||
],
|
||||
welcomeCard: welcomeCardDefault,
|
||||
thankYouCard: thankYouCardDefault,
|
||||
hiddenFields: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const findTemplateByName = (name: string): TTemplate | undefined => {
|
||||
|
||||
@@ -79,6 +79,15 @@ export default function BestPracticeNavigation() {
|
||||
description: "Give users the chance to share feedback in a single click.",
|
||||
category: "Boost Retention",
|
||||
},
|
||||
|
||||
{
|
||||
name: "Improve Newsletter Content",
|
||||
href: "/improve-newsletter-content",
|
||||
status: true,
|
||||
icon: FeedbackIcon,
|
||||
description: "Improve your newsletter content by showing this survey to your readers.",
|
||||
category: "Boost Retention",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
@@ -60,7 +60,7 @@ export default function LayoutMdx({ meta, children }: Props) {
|
||||
)}
|
||||
</header>
|
||||
)}
|
||||
<Prose className="prose-h2:text-2xl prose-li:text-base prose-h2:mt-4 prose-p:text-base prose-p:mb-4 prose-h3:text-xl prose-a:text-slate-900 prose-a:hover:text-slate-900 prose-a:text-decoration-brand prose-a:not-italic prose-ul:pl-12">
|
||||
<Prose className="prose-h2:text-2xl prose-li:text-base prose-h2:mt-6 prose-p:text-base prose-p:mb-4 prose-h3:text-xl prose-a:text-slate-900 prose-a:hover:text-slate-900 prose-a:text-decoration-brand prose-a:not-italic prose-ul:pl-12">
|
||||
{children}
|
||||
</Prose>
|
||||
</article>
|
||||
|
||||
@@ -155,11 +155,6 @@ const nextConfig = {
|
||||
destination: "/docs/self-hosting/migration-guide",
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: "/cla",
|
||||
destination: "https://formbricks.com/clmyhzfrymr4ko00hycsg1tvx",
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: "/docs/contributing/gitpod",
|
||||
destination: "/docs/contributing/setup#gitpod",
|
||||
|
||||
@@ -33,6 +33,12 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
|
||||
"Cal.com is a scheduling tool that helps you schedule meetings without the back-and-forth emails.",
|
||||
href: "https://cal.com",
|
||||
},
|
||||
{
|
||||
name: "ClassroomIO.com",
|
||||
description:
|
||||
"ClassroomIO is a no-code tool that allows you build and scale your own teaching platform with ease.",
|
||||
href: "https://www.classroomio.com",
|
||||
},
|
||||
{
|
||||
name: "Crowd.dev",
|
||||
description:
|
||||
|
||||
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 92 KiB |
@@ -0,0 +1,165 @@
|
||||
import AuthorBox from "@/components/shared/AuthorBox";
|
||||
import { Callout } from "@/components/shared/Callout";
|
||||
import LayoutMdx from "@/components/shared/LayoutMdx";
|
||||
import Image from "next/image";
|
||||
import NewsletterSurveyCode from './code-for-embedding-newsletter-survey-formbricks.png';
|
||||
import Header from './header-newsletter-survey-content-open-source-kpi.webp';
|
||||
import NewsletterSurvey from './improve-newsletter-content-editor-formbricks.png';
|
||||
import NewsletterSurveyModal from './improve-newsletter-content-modal-formbricks.png';
|
||||
import SurveyResponses from './survey-responses.png';
|
||||
import YourSurveys from './your-surveys-formbricks.png';
|
||||
export const meta = {
|
||||
title: "How to measure the quality of your newsletter content with Formbricks",
|
||||
description:
|
||||
"Newsletters boast the highest ROI in email marketing but grasping your audience's resonance is key. Formbricks steps in to empower you with tools to measure your content quality and explore reader engagement in newsletters.",
|
||||
date: "2024-01-24",
|
||||
publishedTime: "2024-01-24T12:00:00",
|
||||
authors: ["Olasunkanmi Balogun"],
|
||||
section: "Newsletter Survey",
|
||||
tags: ["Improve Newsletter Content"],
|
||||
};
|
||||
|
||||
<Image src={Header} alt="Gather in app feedback for free with these 6 tools." className="w-full rounded-lg" />
|
||||
|
||||
<AuthorBox
|
||||
name="Olasunkanmi Balogun"
|
||||
title="Content Writer"
|
||||
date="January 24th, 2024"
|
||||
duration="5"
|
||||
author={"Ola"}
|
||||
/>
|
||||
|
||||
_Newsletters are a form of email marketing which has the highest ROI of all marketing channels. According to the research (source below), for every dollar spent you can get a return of $36 to $40._
|
||||
|
||||
You reap this sweet, juciy ROI if your content resonates with your audience. You can only manage what you measure, so here is a quick, free and easy way to measure the quality of your newsletter content.
|
||||
|
||||
After this article, you have everything you need to easily measure the quality of your emails. We walk through you the setup, how to embed it in your email and what to do with the insights.
|
||||
|
||||
Here’s what we’ll cover:
|
||||
|
||||
1. **How link prefilling and user identification maximize your insights**
|
||||
2. **How to create the right survey with Formbricks**
|
||||
3. **Embeddung the survey in your email**
|
||||
4. **How to improve the analysis by identifying users**
|
||||
5. **How to enrich the response with hidden fields**
|
||||
6. **Auto-create surveys via API**
|
||||
|
||||
Toes are in the water, let’s dive deep 🏊
|
||||
|
||||
<Callout title="What Is Formbricks" type="note">
|
||||
Formbricks is an experience management suite built on the **largest open source survey stack** worldwide. It’s easy to use and packs all integrations you'd need.
|
||||
When you communicate via email, Formbricks bridges the gap between you and your readers. It packs everything you need to measure how happy users are with your newsletter content.
|
||||
</Callout>
|
||||
|
||||
## How link prefilling and user identification maximize your insights
|
||||
|
||||
To effectively measure the quality of your newsletter, you need:
|
||||
|
||||
- **Survey Embed Code:** A code snippet to embed the survey in the email (provided by Formbricks)
|
||||
- **Link Prefilling**: So that if a reader clicks on one of the stars or smileys in your rating survey, the data is stored and the first question is skipped (more on this later).
|
||||
- **Dynamic User Identification:** You can append the email of the reader dynamically to the survey to automatically create a personal profile for the reader. Hence, if this person provides any other feedback in their customer lifetime, it is gathered in the person's view.
|
||||
|
||||
Let's see how to stack these features to get what you're looking for 🤓
|
||||
|
||||
## How to create the right survey with Formbricks
|
||||
|
||||
1. After signing up on the Formbricks platform, you’ll be navigated to your dashboard. To create a survey for your newsletter, select the **Growth** filter and choose the “**Improve Newsletter Content**” survey template.
|
||||
|
||||
<Image
|
||||
src={YourSurveys}
|
||||
alt="Formbricks growth survey templates"
|
||||
className="w-full rounded-lg"
|
||||
/>
|
||||
|
||||
2. You’ll be directed to a page that will be your canvas for customizing your survey questions. For your survey’s first question, you can display smileys, stars, or even numbers for your survey rating; in the image below, we used a smiley.
|
||||
|
||||
<Callout title="Dynamic Follow-up Questions with Logic Jumps" type="note">
|
||||
You want to ask different follow up questions based on the rating? No problem with Logic Jumps!
|
||||
</Callout>
|
||||
|
||||
<Image
|
||||
src={NewsletterSurvey}
|
||||
alt="Formbricks newsletter survey editor"
|
||||
className="w-full rounded-lg"
|
||||
/>
|
||||
|
||||
3. When you are done customizing your survey questions, navigate to the **Settings** tab and choose the type of survey you want. Here, you have to pick Link Survey.
|
||||
|
||||
4. After configuring the survey settings however you like, click the **Publish** button on the top right corner of the page, and a modal should pop up once this is successful.
|
||||
|
||||
|
||||
## Embedding the survey in your email
|
||||
<Image
|
||||
src={NewsletterSurveyModal}
|
||||
alt="Formbricks newsletter survey modal"
|
||||
className="w-full rounded-lg"
|
||||
/>
|
||||
|
||||
Since you would traditionally want to embed this survey in your email, select the **Embed Survey** card. You will be directed to another window, where you'll find **Embed in Email**.
|
||||
|
||||
<Image
|
||||
src={NewsletterSurveyCode}
|
||||
alt="Formbricks newsletter survey embed code"
|
||||
className="w-full rounded-lg"
|
||||
/>
|
||||
|
||||
5. Finally, click the button with the “**View Embed Code**” text at the top right corner of the modal and simply copy the HTML code for your survey anywhere you want it in your newsletter. You can see the preview in the above image.
|
||||
|
||||
So, how does this work under the hood? In the next section, we’ll see how Formbricks utilizes link prefilling to create personalized responses for you.
|
||||
|
||||
## How to improve the analysis by identifying users
|
||||
|
||||
Apart from the [data prefilling](https://formbricks.com/docs/link-surveys/data-prefilling) needed to store which rating a user clicked on in an email, we use Formbricks link identification.
|
||||
|
||||
Link identification lets you link a response to a person. Whenever you set a `userId` in the URL, the Formbricks backend will create a new person profile.
|
||||
|
||||
This is what the link looks like:
|
||||
|
||||
`https://formbricks.com/s/clrgp68g2569g1225h3f5ayql?rating=5&userId=johannes@formbricks.com`
|
||||
|
||||
### When do I need identified users?
|
||||
|
||||
This is mostly useful if you know that you will collect more feedback from this one person. For example, if you run an in-app or website survey, you can also pass the userId to Formbricks and both responses will be attributed to the same user.
|
||||
|
||||
This obiously also works for surveys sent to the same user over the customer lifetime.
|
||||
|
||||
In your dashboard on Formbricks, here’s how it will look:
|
||||
|
||||
<Image
|
||||
src={SurveyResponses}
|
||||
alt="Formbricks personalized survey responses"
|
||||
className="w-full rounded-lg"
|
||||
/>
|
||||
|
||||
As Johannes completes the survey, you'll see a personalized, full response from him.
|
||||
|
||||
## How to use Hidden Fields to attach more info to responses
|
||||
|
||||
There might be more information you already have and want to use in the analysis of your survey results. A good way to facilitate that is using Hidden Fields.
|
||||
|
||||
Hidden Fields - as you can tell from the name - do not appear in the survey flow. They can be filled via URL as follows:
|
||||
|
||||
`?fieldId=Content`
|
||||
|
||||
So for example: `?job=Founder`. Or combinbed with the rating and identification parameters:
|
||||
|
||||
`https://formbricks.com/s/clrgp68g2569g1225h3f5ayql?rating=5&userId=johannes@formbricks.com&fieldId=Founder`
|
||||
|
||||
As you see, the survey URL is powerful ally when it comes to getting the most out of your survey respondents 😎
|
||||
|
||||
<Callout title="Auto-create surveys via API" type="note">
|
||||
If you send out a lot of emails periodically, it might be worth creating your surveys automatically. Formbricks has an API for it!
|
||||
|
||||
Going through that in detail would lead to far, but here is all the [documentation you need.](https://formbricks.com/docs/api/management/surveys#create-survey) We're also more than happy helping you set it up in our [Discord](https://formbricks.com/discord)
|
||||
</Callout>
|
||||
|
||||
## In Conclusion
|
||||
|
||||
Understanding what resonates with your readers is the compass that points toward growth, and so far, we’ve seen how Formbricks can help you achieve this. Formbricks not only provides actionable insights but also serves as a bridge, closing the gap between your content and reader satisfaction.
|
||||
|
||||
Formbricks is free and easy to get started with. All you have to do is [create an account](http://formbricks.com/) and you are good to go 🚀
|
||||
|
||||
Source for ROI figure:
|
||||
[Omnisend Blog](https://www.omnisend.com/blog/email-marketing-roi/#:~:text=What's%20an%20average%20email%20marketing,%2440%20for%20every%20dollar%20spent.)
|
||||
|
||||
export default ({ children }) => <LayoutMdx meta={meta}>{children}</LayoutMdx>;
|
||||
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 115 KiB |
@@ -1,10 +1,11 @@
|
||||
import Image from "next/image";
|
||||
import LayoutMdx from "@/components/shared/LayoutMdx";
|
||||
import MonorepoImage from "./formbricks-monorepo-folder-structure.png";
|
||||
import HeaderImage from "./create-a-new-survey-with-formbricks.png";
|
||||
import GitpodImage from "./setup-formbricks-via-gitpod.png";
|
||||
import PackagesFolderImage from "./formbricks-packages-folder.png";
|
||||
import AuthorBox from "@/components/shared/AuthorBox";
|
||||
import LayoutMdx from "@/components/shared/LayoutMdx";
|
||||
import Image from "next/image";
|
||||
|
||||
import HeaderImage from "./create-a-new-survey-with-formbricks.png";
|
||||
import MonorepoImage from "./formbricks-monorepo-folder-structure.png";
|
||||
import PackagesFolderImage from "./formbricks-packages-folder.png";
|
||||
import GitpodImage from "./setup-formbricks-via-gitpod.png";
|
||||
|
||||
export const meta = {
|
||||
title: "Join the FormTribe 🔥",
|
||||
@@ -16,7 +17,7 @@ export const meta = {
|
||||
tags: ["Open-Source", "No-Code", "Formbricks", "Geting started", "Welcome guide"],
|
||||
};
|
||||
|
||||
<AuthorBox name="Johannes" title="Co-Founder" date="October 1st, 2023" duration="4" author={"Johannes"}/>
|
||||
<AuthorBox name="Johannes" title="Co-Founder" date="October 1st, 2023" duration="4" author={"Johannes"} />
|
||||
|
||||
<Image src={HeaderImage} alt="Title Image" className="w-full rounded-lg" />
|
||||
|
||||
@@ -54,10 +55,10 @@ To get up and running we have 2 options: Gitpod and local.
|
||||
|
||||
With Gitpod you can run all of Formbricks in the cloud. With one click you can start coding right away in your browser:
|
||||
|
||||
[](https://gitpod.io/#https://github.com/formbricks/formbricks)
|
||||
|
||||
<Image src={GitpodImage} alt="Setup Formbricks via Gitpod" className="w-full rounded-lg" />
|
||||
|
||||
[Read more in our docs](https://formbricks.com/docs/contributing/setup#gitpod-guide)
|
||||
|
||||
#### Run on a local machine
|
||||
|
||||
If you choose to get setup locally, we also have a well documented guide to hold you through the process, you can find it [here](https://formbricks.com/docs/contributing/setup)
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import Image from "next/image";
|
||||
import LayoutMdx from "@/components/shared/LayoutMdx";
|
||||
import AuthorBox from "@/components/shared/AuthorBox";
|
||||
import { Callout } from "@/components/shared/Callout";
|
||||
import { Logo } from "@/components/shared/Logo";
|
||||
import TitleImage from "./title-image.png";
|
||||
import LayoutMdx from "@/components/shared/LayoutMdx";
|
||||
import Image from "next/image";
|
||||
import ResponsiveEmbed from "react-responsive-embed";
|
||||
import HeaderImage from "./formbricks-logo.svg";
|
||||
import ProprietaryDependence from "./propietary-dependence.jpeg";
|
||||
import ResponsiveEmbed from "react-responsive-embed";
|
||||
import AuthorBox from "@/components/shared/AuthorBox";
|
||||
|
||||
|
||||
import TitleImage from "./title-image.png";
|
||||
export const meta = {
|
||||
title: "Why Open-Source + No-Code is the Future of Enterprise & Goverment Software",
|
||||
description:
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import Layout from "@/components/demo/LayoutLight";
|
||||
import DemoView from "@/components/dummyUI/DemoView";
|
||||
import LayoutWaitlist from "@/pages/demo/LayoutLight";
|
||||
|
||||
export default function DemoPage() {
|
||||
return (
|
||||
<LayoutWaitlist
|
||||
<Layout
|
||||
title="Formbricks Demo"
|
||||
description="Play around with our pre-defined 30+ templates and them to kick-start your survey & experience management.">
|
||||
<DemoView />
|
||||
</LayoutWaitlist>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@ ENV NEXTAUTH_SECRET=$NEXTAUTH_SECRET
|
||||
ARG ENCRYPTION_KEY
|
||||
ENV ENCRYPTION_KEY=$ENCRYPTION_KEY
|
||||
|
||||
ARG NEXT_PUBLIC_SENTRY_DSN
|
||||
|
||||
# Set the working directory
|
||||
WORKDIR /app
|
||||
|
||||
@@ -76,4 +78,4 @@ CMD supercronic -quiet /app/docker/cronjobs & \
|
||||
else \
|
||||
echo "ERROR: Please set a value for NEXTAUTH_SECRET in your docker compose variables!" >&2; \
|
||||
exit 1; \
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
"use server";
|
||||
|
||||
import { Team } from "@prisma/client";
|
||||
import { Prisma as prismaClient } from "@prisma/client/";
|
||||
import { getServerSession } from "next-auth";
|
||||
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { authOptions } from "@formbricks/lib/authOptions";
|
||||
import { SHORT_URL_BASE, WEBAPP_URL } from "@formbricks/lib/constants";
|
||||
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
|
||||
import { createMembership } from "@formbricks/lib/membership/service";
|
||||
import { createProduct } from "@formbricks/lib/product/service";
|
||||
import { createShortUrl } from "@formbricks/lib/shortUrl/service";
|
||||
import { canUserAccessSurvey, verifyUserRoleAccess } from "@formbricks/lib/survey/auth";
|
||||
import { surveyCache } from "@formbricks/lib/survey/cache";
|
||||
import { deleteSurvey, duplicateSurvey, getSurvey } from "@formbricks/lib/survey/service";
|
||||
import { createTeam, getTeamByEnvironmentId } from "@formbricks/lib/team/service";
|
||||
import { updateUser } from "@formbricks/lib/user/service";
|
||||
import { AuthenticationError, AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
|
||||
export const createShortUrlAction = async (url: string) => {
|
||||
@@ -44,193 +40,28 @@ export async function createTeamAction(teamName: string): Promise<Team> {
|
||||
accepted: true,
|
||||
});
|
||||
|
||||
await createProduct(newTeam.id, {
|
||||
const product = await createProduct(newTeam.id, {
|
||||
name: "My Product",
|
||||
});
|
||||
|
||||
const updatedNotificationSettings = {
|
||||
...session.user.notificationSettings,
|
||||
alert: {
|
||||
...session.user.notificationSettings?.alert,
|
||||
},
|
||||
weeklySummary: {
|
||||
...session.user.notificationSettings?.weeklySummary,
|
||||
[product.id]: true,
|
||||
},
|
||||
};
|
||||
|
||||
await updateUser(session.user.id, {
|
||||
notificationSettings: updatedNotificationSettings,
|
||||
});
|
||||
|
||||
return newTeam;
|
||||
}
|
||||
|
||||
export async function duplicateSurveyAction(environmentId: string, surveyId: string) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) throw new AuthorizationError("Not authorized");
|
||||
|
||||
const isAuthorized = await canUserAccessSurvey(session.user.id, surveyId);
|
||||
if (!isAuthorized) throw new AuthorizationError("Not authorized");
|
||||
|
||||
const duplicatedSurvey = await duplicateSurvey(environmentId, surveyId);
|
||||
return duplicatedSurvey;
|
||||
}
|
||||
|
||||
export async function copyToOtherEnvironmentAction(
|
||||
environmentId: string,
|
||||
surveyId: string,
|
||||
targetEnvironmentId: string
|
||||
) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) throw new AuthorizationError("Not authorized");
|
||||
|
||||
const isAuthorizedToAccessSourceEnvironment = await hasUserEnvironmentAccess(
|
||||
session.user.id,
|
||||
environmentId
|
||||
);
|
||||
if (!isAuthorizedToAccessSourceEnvironment) throw new AuthorizationError("Not authorized");
|
||||
|
||||
const isAuthorizedToAccessTargetEnvironment = await hasUserEnvironmentAccess(
|
||||
session.user.id,
|
||||
targetEnvironmentId
|
||||
);
|
||||
if (!isAuthorizedToAccessTargetEnvironment) throw new AuthorizationError("Not authorized");
|
||||
|
||||
const isAuthorized = await canUserAccessSurvey(session.user.id, surveyId);
|
||||
if (!isAuthorized) throw new AuthorizationError("Not authorized");
|
||||
|
||||
const existingSurvey = await prisma.survey.findFirst({
|
||||
where: {
|
||||
id: surveyId,
|
||||
environmentId,
|
||||
},
|
||||
include: {
|
||||
triggers: {
|
||||
include: {
|
||||
actionClass: true,
|
||||
},
|
||||
},
|
||||
attributeFilters: {
|
||||
include: {
|
||||
attributeClass: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!existingSurvey) {
|
||||
throw new ResourceNotFoundError("Survey", surveyId);
|
||||
}
|
||||
|
||||
let targetEnvironmentTriggers: string[] = [];
|
||||
// map the local triggers to the target environment
|
||||
for (const trigger of existingSurvey.triggers) {
|
||||
const targetEnvironmentTrigger = await prisma.actionClass.findFirst({
|
||||
where: {
|
||||
name: trigger.actionClass.name,
|
||||
environment: {
|
||||
id: targetEnvironmentId,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!targetEnvironmentTrigger) {
|
||||
// if the trigger does not exist in the target environment, create it
|
||||
const newTrigger = await prisma.actionClass.create({
|
||||
data: {
|
||||
name: trigger.actionClass.name,
|
||||
environment: {
|
||||
connect: {
|
||||
id: targetEnvironmentId,
|
||||
},
|
||||
},
|
||||
description: trigger.actionClass.description,
|
||||
type: trigger.actionClass.type,
|
||||
noCodeConfig: trigger.actionClass.noCodeConfig
|
||||
? JSON.parse(JSON.stringify(trigger.actionClass.noCodeConfig))
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
targetEnvironmentTriggers.push(newTrigger.id);
|
||||
} else {
|
||||
targetEnvironmentTriggers.push(targetEnvironmentTrigger.id);
|
||||
}
|
||||
}
|
||||
|
||||
let targetEnvironmentAttributeFilters: string[] = [];
|
||||
// map the local attributeFilters to the target env
|
||||
for (const attributeFilter of existingSurvey.attributeFilters) {
|
||||
// check if attributeClass exists in target env.
|
||||
// if not, create it
|
||||
const targetEnvironmentAttributeClass = await prisma.attributeClass.findFirst({
|
||||
where: {
|
||||
name: attributeFilter.attributeClass.name,
|
||||
environment: {
|
||||
id: targetEnvironmentId,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!targetEnvironmentAttributeClass) {
|
||||
const newAttributeClass = await prisma.attributeClass.create({
|
||||
data: {
|
||||
name: attributeFilter.attributeClass.name,
|
||||
description: attributeFilter.attributeClass.description,
|
||||
type: attributeFilter.attributeClass.type,
|
||||
environment: {
|
||||
connect: {
|
||||
id: targetEnvironmentId,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
targetEnvironmentAttributeFilters.push(newAttributeClass.id);
|
||||
} else {
|
||||
targetEnvironmentAttributeFilters.push(targetEnvironmentAttributeClass.id);
|
||||
}
|
||||
}
|
||||
|
||||
// create new survey with the data of the existing survey
|
||||
const newSurvey = await prisma.survey.create({
|
||||
data: {
|
||||
...existingSurvey,
|
||||
id: undefined, // id is auto-generated
|
||||
environmentId: undefined, // environmentId is set below
|
||||
name: `${existingSurvey.name} (copy)`,
|
||||
status: "draft",
|
||||
questions: JSON.parse(JSON.stringify(existingSurvey.questions)),
|
||||
thankYouCard: JSON.parse(JSON.stringify(existingSurvey.thankYouCard)),
|
||||
triggers: {
|
||||
create: targetEnvironmentTriggers.map((actionClassId) => ({
|
||||
actionClassId: actionClassId,
|
||||
})),
|
||||
},
|
||||
attributeFilters: {
|
||||
create: existingSurvey.attributeFilters.map((attributeFilter, idx) => ({
|
||||
attributeClassId: targetEnvironmentAttributeFilters[idx],
|
||||
condition: attributeFilter.condition,
|
||||
value: attributeFilter.value,
|
||||
})),
|
||||
},
|
||||
environment: {
|
||||
connect: {
|
||||
id: targetEnvironmentId,
|
||||
},
|
||||
},
|
||||
surveyClosedMessage: existingSurvey.surveyClosedMessage ?? prismaClient.JsonNull,
|
||||
singleUse: existingSurvey.singleUse ?? prismaClient.JsonNull,
|
||||
productOverwrites: existingSurvey.productOverwrites ?? prismaClient.JsonNull,
|
||||
verifyEmail: existingSurvey.verifyEmail ?? prismaClient.JsonNull,
|
||||
styling: existingSurvey.styling ?? prismaClient.JsonNull,
|
||||
},
|
||||
});
|
||||
|
||||
surveyCache.revalidate({
|
||||
id: newSurvey.id,
|
||||
environmentId: targetEnvironmentId,
|
||||
});
|
||||
return newSurvey;
|
||||
}
|
||||
|
||||
export const deleteSurveyAction = async (surveyId: string) => {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) throw new AuthorizationError("Not authorized");
|
||||
|
||||
const isAuthorized = await canUserAccessSurvey(session.user.id, surveyId);
|
||||
if (!isAuthorized) throw new AuthorizationError("Not authorized");
|
||||
|
||||
const survey = await getSurvey(surveyId);
|
||||
|
||||
const { hasDeleteAccess } = await verifyUserRoleAccess(survey!.environmentId, session.user.id);
|
||||
if (!hasDeleteAccess) throw new AuthorizationError("Not authorized");
|
||||
|
||||
await deleteSurvey(surveyId);
|
||||
};
|
||||
|
||||
export const createProductAction = async (environmentId: string, productName: string) => {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) throw new AuthorizationError("Not authorized");
|
||||
@@ -244,6 +75,20 @@ export const createProductAction = async (environmentId: string, productName: st
|
||||
const product = await createProduct(team.id, {
|
||||
name: productName,
|
||||
});
|
||||
const updatedNotificationSettings = {
|
||||
...session.user.notificationSettings,
|
||||
alert: {
|
||||
...session.user.notificationSettings?.alert,
|
||||
},
|
||||
weeklySummary: {
|
||||
...session.user.notificationSettings?.weeklySummary,
|
||||
[product.id]: true,
|
||||
},
|
||||
};
|
||||
|
||||
await updateUser(session.user.id, {
|
||||
notificationSettings: updatedNotificationSettings,
|
||||
});
|
||||
|
||||
// get production environment
|
||||
const productionEnvironment = product.environments.find((environment) => environment.type === "production");
|
||||
|
||||
@@ -504,7 +504,7 @@ export default function Navigation({
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={async () => {
|
||||
await signOut();
|
||||
await signOut({ callbackUrl: "/auth/login" });
|
||||
await formbricksLogout();
|
||||
}}>
|
||||
<div className="flex h-full w-full items-center">
|
||||
|
||||
@@ -98,8 +98,11 @@ export const leaveTeamAction = async (teamId: string) => {
|
||||
};
|
||||
|
||||
export const createInviteTokenAction = async (inviteId: string) => {
|
||||
const { email } = await getInvite(inviteId);
|
||||
const inviteToken = createInviteToken(inviteId, email, {
|
||||
const invite = await getInvite(inviteId);
|
||||
if (!invite) {
|
||||
throw new ValidationError("Invite not found");
|
||||
}
|
||||
const inviteToken = createInviteToken(inviteId, invite.email, {
|
||||
expiresIn: "7d",
|
||||
});
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
import { getServerSession } from "next-auth";
|
||||
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { authOptions } from "@formbricks/lib/authOptions";
|
||||
import { updateUser } from "@formbricks/lib/user/service";
|
||||
import { AuthorizationError } from "@formbricks/types/errors";
|
||||
import { TUserNotificationSettings } from "@formbricks/types/user";
|
||||
|
||||
@@ -13,9 +13,7 @@ export async function updateNotificationSettingsAction(notificationSettings: TUs
|
||||
throw new AuthorizationError("Not authenticated");
|
||||
}
|
||||
|
||||
// update user with notification settings
|
||||
await prisma.user.update({
|
||||
where: { id: session.user.id },
|
||||
data: { notificationSettings },
|
||||
await updateUser(session.user.id, {
|
||||
notificationSettings,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,27 +1,49 @@
|
||||
import { QuestionMarkCircleIcon, UsersIcon } from "@heroicons/react/24/solid";
|
||||
import Link from "next/link";
|
||||
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@formbricks/ui/Tooltip";
|
||||
|
||||
import { Membership, User } from "../types";
|
||||
import { Membership } from "../types";
|
||||
import { NotificationSwitch } from "./NotificationSwitch";
|
||||
|
||||
interface EditAlertsProps {
|
||||
memberships: Membership[];
|
||||
user: User;
|
||||
user: TUser;
|
||||
environmentId: string;
|
||||
autoDisableNotificationType: string;
|
||||
autoDisableNotificationElementId: string;
|
||||
}
|
||||
|
||||
export default function EditAlerts({ memberships, user, environmentId }: EditAlertsProps) {
|
||||
export default function EditAlerts({
|
||||
memberships,
|
||||
user,
|
||||
environmentId,
|
||||
autoDisableNotificationType,
|
||||
autoDisableNotificationElementId,
|
||||
}: EditAlertsProps) {
|
||||
return (
|
||||
<>
|
||||
{memberships.map((membership) => (
|
||||
<>
|
||||
<div className="mb-5 flex items-center space-x-3 font-semibold">
|
||||
<div className="rounded-full bg-slate-100 p-1">
|
||||
<UsersIcon className="h-6 w-7 text-slate-600" />
|
||||
<div className="mb-5 grid grid-cols-6 items-center space-x-3">
|
||||
<div className="col-span-3 flex items-center space-x-3">
|
||||
<div className="rounded-full bg-slate-100 p-1">
|
||||
<UsersIcon className="h-6 w-7 text-slate-600" />
|
||||
</div>
|
||||
<p className="font-semibold text-slate-800">{membership.team.name}</p>
|
||||
</div>
|
||||
|
||||
<div className="col-span-3 flex items-center justify-end pr-2">
|
||||
<p className="pr-4 text-sm text-slate-600">Auto-subscribe to new surveys</p>
|
||||
<NotificationSwitch
|
||||
surveyOrProductOrTeamId={membership.team.id}
|
||||
notificationSettings={user.notificationSettings!}
|
||||
notificationType={"unsubscribedTeamIds"}
|
||||
autoDisableNotificationType={autoDisableNotificationType}
|
||||
autoDisableNotificationElementId={autoDisableNotificationElementId}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-slate-800">{membership.team.name}</p>
|
||||
</div>
|
||||
<div className="mb-6 rounded-lg border border-slate-200">
|
||||
<div className="grid h-12 grid-cols-3 content-center rounded-t-lg bg-slate-100 px-4 text-left text-sm font-semibold text-slate-900">
|
||||
@@ -57,9 +79,11 @@ export default function EditAlerts({ memberships, user, environmentId }: EditAle
|
||||
</div>
|
||||
<div className="col-span-1 text-center">
|
||||
<NotificationSwitch
|
||||
surveyOrProductId={survey.id}
|
||||
notificationSettings={user.notificationSettings}
|
||||
surveyOrProductOrTeamId={survey.id}
|
||||
notificationSettings={user.notificationSettings!}
|
||||
notificationType={"alert"}
|
||||
autoDisableNotificationType={autoDisableNotificationType}
|
||||
autoDisableNotificationElementId={autoDisableNotificationElementId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { UsersIcon } from "@heroicons/react/24/solid";
|
||||
import Link from "next/link";
|
||||
|
||||
import { Membership, User } from "../types";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
|
||||
import { Membership } from "../types";
|
||||
import { NotificationSwitch } from "./NotificationSwitch";
|
||||
|
||||
interface EditAlertsProps {
|
||||
memberships: Membership[];
|
||||
user: User;
|
||||
user: TUser;
|
||||
environmentId: string;
|
||||
}
|
||||
|
||||
@@ -34,8 +36,8 @@ export default function EditWeeklySummary({ memberships, user, environmentId }:
|
||||
<div className="col-span-2">{product?.name}</div>
|
||||
<div className="col-span-1 flex items-center justify-center">
|
||||
<NotificationSwitch
|
||||
surveyOrProductId={product.id}
|
||||
notificationSettings={user.notificationSettings}
|
||||
surveyOrProductOrTeamId={product.id}
|
||||
notificationSettings={user.notificationSettings!}
|
||||
notificationType={"weeklySummary"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { TUserNotificationSettings } from "@formbricks/types/user";
|
||||
@@ -10,35 +9,90 @@ import { Switch } from "@formbricks/ui/Switch";
|
||||
import { updateNotificationSettingsAction } from "../actions";
|
||||
|
||||
interface NotificationSwitchProps {
|
||||
surveyOrProductId: string;
|
||||
surveyOrProductOrTeamId: string;
|
||||
notificationSettings: TUserNotificationSettings;
|
||||
notificationType: "alert" | "weeklySummary";
|
||||
notificationType: "alert" | "weeklySummary" | "unsubscribedTeamIds";
|
||||
autoDisableNotificationType?: string;
|
||||
autoDisableNotificationElementId?: string;
|
||||
}
|
||||
|
||||
export function NotificationSwitch({
|
||||
surveyOrProductId,
|
||||
surveyOrProductOrTeamId,
|
||||
notificationSettings,
|
||||
notificationType,
|
||||
autoDisableNotificationType,
|
||||
autoDisableNotificationElementId,
|
||||
}: NotificationSwitchProps) {
|
||||
const router = useRouter();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const isChecked =
|
||||
notificationType === "unsubscribedTeamIds"
|
||||
? !notificationSettings.unsubscribedTeamIds?.includes(surveyOrProductOrTeamId)
|
||||
: notificationSettings[notificationType][surveyOrProductOrTeamId] === true;
|
||||
|
||||
const handleSwitchChange = async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
let updatedNotificationSettings = { ...notificationSettings };
|
||||
if (notificationType === "unsubscribedTeamIds") {
|
||||
const unsubscribedTeamIds = updatedNotificationSettings.unsubscribedTeamIds ?? [];
|
||||
if (unsubscribedTeamIds.includes(surveyOrProductOrTeamId)) {
|
||||
updatedNotificationSettings.unsubscribedTeamIds = unsubscribedTeamIds.filter(
|
||||
(id) => id !== surveyOrProductOrTeamId
|
||||
);
|
||||
} else {
|
||||
updatedNotificationSettings.unsubscribedTeamIds = [...unsubscribedTeamIds, surveyOrProductOrTeamId];
|
||||
}
|
||||
} else {
|
||||
updatedNotificationSettings[notificationType][surveyOrProductOrTeamId] =
|
||||
!updatedNotificationSettings[notificationType][surveyOrProductOrTeamId];
|
||||
}
|
||||
|
||||
await updateNotificationSettingsAction(updatedNotificationSettings);
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
autoDisableNotificationType &&
|
||||
autoDisableNotificationElementId === surveyOrProductOrTeamId &&
|
||||
isChecked
|
||||
) {
|
||||
switch (notificationType) {
|
||||
case "alert":
|
||||
if (notificationSettings[notificationType][surveyOrProductOrTeamId] === true) {
|
||||
handleSwitchChange();
|
||||
toast.success("You will not receive any more emails for responses on this survey!", {
|
||||
id: "notification-switch",
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case "unsubscribedTeamIds":
|
||||
if (!notificationSettings.unsubscribedTeamIds?.includes(surveyOrProductOrTeamId)) {
|
||||
handleSwitchChange();
|
||||
toast.success("You will not be auto-subscribed to this team's surveys anymore!", {
|
||||
id: "notification-switch",
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Switch
|
||||
id="notification-switch"
|
||||
aria-label="toggle notification settings"
|
||||
checked={notificationSettings[notificationType][surveyOrProductId]}
|
||||
aria-label={`toggle notification settings for ${notificationType}`}
|
||||
checked={isChecked}
|
||||
disabled={isLoading}
|
||||
onCheckedChange={async () => {
|
||||
setIsLoading(true);
|
||||
// update notificiation settings
|
||||
const updatedNotificationSettings = { ...notificationSettings };
|
||||
updatedNotificationSettings[notificationType][surveyOrProductId] =
|
||||
!updatedNotificationSettings[notificationType][surveyOrProductId];
|
||||
await updateNotificationSettingsAction(notificationSettings);
|
||||
setIsLoading(false);
|
||||
toast.success(`Notification settings updated`, { id: "notification-switch" });
|
||||
router.refresh();
|
||||
await handleSwitchChange();
|
||||
toast.success("Notification settings updated", { id: "notification-switch" });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -3,42 +3,24 @@ import { getServerSession } from "next-auth";
|
||||
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { authOptions } from "@formbricks/lib/authOptions";
|
||||
import { getUser } from "@formbricks/lib/user/service";
|
||||
import { TUserNotificationSettings } from "@formbricks/types/user";
|
||||
|
||||
import SettingsTitle from "../components/SettingsTitle";
|
||||
import EditAlerts from "./components/EditAlerts";
|
||||
import EditWeeklySummary from "./components/EditWeeklySummary";
|
||||
import IntegrationsTip from "./components/IntegrationsTip";
|
||||
import type { Membership, User } from "./types";
|
||||
import type { Membership } from "./types";
|
||||
|
||||
async function getUser(userId: string | undefined): Promise<User> {
|
||||
if (!userId) {
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
const userData = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
notificationSettings: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!userData) {
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
|
||||
const user = JSON.parse(JSON.stringify(userData)); // hack to remove the JsonValue type from the notificationSettings
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
function cleanNotificationSettings(
|
||||
function setCompleteNotificationSettings(
|
||||
notificationSettings: TUserNotificationSettings,
|
||||
memberships: Membership[]
|
||||
) {
|
||||
const newNotificationSettings = { alert: {}, weeklySummary: {} };
|
||||
): TUserNotificationSettings {
|
||||
const newNotificationSettings = {
|
||||
alert: {},
|
||||
weeklySummary: {},
|
||||
unsubscribedTeamIds: notificationSettings.unsubscribedTeamIds || [],
|
||||
};
|
||||
for (const membership of memberships) {
|
||||
for (const product of membership.team.products) {
|
||||
// set default values for weekly summary
|
||||
@@ -95,13 +77,22 @@ async function getMemberships(userId: string): Promise<Membership[]> {
|
||||
return memberships;
|
||||
}
|
||||
|
||||
export default async function ProfileSettingsPage({ params }) {
|
||||
export default async function ProfileSettingsPage({ params, searchParams }) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) {
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
const autoDisableNotificationType = searchParams["type"];
|
||||
const autoDisableNotificationElementId = searchParams["elementId"];
|
||||
|
||||
const [user, memberships] = await Promise.all([getUser(session.user.id), getMemberships(session.user.id)]);
|
||||
user.notificationSettings = cleanNotificationSettings(user.notificationSettings, memberships);
|
||||
if (!user) {
|
||||
throw new Error("User not found");
|
||||
}
|
||||
|
||||
if (user?.notificationSettings) {
|
||||
user.notificationSettings = setCompleteNotificationSettings(user.notificationSettings, memberships);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -109,11 +100,16 @@ export default async function ProfileSettingsPage({ params }) {
|
||||
<SettingsCard
|
||||
title="Email alerts (Surveys)"
|
||||
description="Set up an alert to get an email on new responses.">
|
||||
<EditAlerts memberships={memberships} user={user} environmentId={params.environmentId} />
|
||||
<EditAlerts
|
||||
memberships={memberships}
|
||||
user={user}
|
||||
environmentId={params.environmentId}
|
||||
autoDisableNotificationType={autoDisableNotificationType}
|
||||
autoDisableNotificationElementId={autoDisableNotificationElementId}
|
||||
/>
|
||||
</SettingsCard>
|
||||
<IntegrationsTip environmentId={params.environmentId} />
|
||||
<SettingsCard
|
||||
beta
|
||||
title="Weekly summary (Products)"
|
||||
description="Stay up-to-date with a Weekly every Monday.">
|
||||
<EditWeeklySummary memberships={memberships} user={user} environmentId={params.environmentId} />
|
||||
|
||||
@@ -55,7 +55,7 @@ function DeleteAccountModal({ setOpen, open, session }: DeleteAccountModalProps)
|
||||
try {
|
||||
setDeleting(true);
|
||||
await deleteUserAction();
|
||||
await signOut();
|
||||
await signOut({ callbackUrl: "/auth/login" });
|
||||
await formbricksLogout();
|
||||
} catch (error) {
|
||||
toast.error("Something went wrong");
|
||||
|
||||
@@ -184,8 +184,8 @@ const CustomFilter = ({ environmentTags, responses, survey, totalResponses }: Cu
|
||||
"Response ID": response.id,
|
||||
Timestamp: response.createdAt,
|
||||
Finished: response.finished,
|
||||
"User ID": response.person?.userId,
|
||||
"Survey ID": response.surveyId,
|
||||
"Formbricks User ID": response.person?.id ?? "",
|
||||
};
|
||||
const metaDataKeys = extracMetadataKeys(response.meta);
|
||||
let metaData = {};
|
||||
@@ -207,7 +207,19 @@ const CustomFilter = ({ environmentTags, responses, survey, totalResponses }: Cu
|
||||
hiddenFieldResponse[hiddenFieldId] = response.data[hiddenFieldId] ?? "";
|
||||
});
|
||||
}
|
||||
const fileResponse = { ...basicInfo, ...metaData, ...personAttributes, ...hiddenFieldResponse };
|
||||
const tags = { Tags: response.tags.map((tag) => tag.name).join(", ") };
|
||||
const notes = {
|
||||
Notes: response.notes.map((note) => `${note.user.name}: ${note.text}`).join("\n"),
|
||||
};
|
||||
|
||||
const fileResponse = {
|
||||
...basicInfo,
|
||||
...metaData,
|
||||
...personAttributes,
|
||||
...hiddenFieldResponse,
|
||||
...tags,
|
||||
...notes,
|
||||
};
|
||||
// Map each question name to its corresponding answer
|
||||
questionNames.forEach((questionName: string) => {
|
||||
const matchingQuestion = response.responses.find((question) => question.question === questionName);
|
||||
@@ -232,7 +244,9 @@ const CustomFilter = ({ environmentTags, responses, survey, totalResponses }: Cu
|
||||
"Timestamp",
|
||||
"Finished",
|
||||
"Survey ID",
|
||||
"Formbricks User ID",
|
||||
"User ID",
|
||||
"Notes",
|
||||
"Tags",
|
||||
...metaDataFields,
|
||||
...questionNames,
|
||||
...(hiddenFieldIds ?? []),
|
||||
|
||||
@@ -27,10 +27,7 @@ export default function SurveyStatusDropdown({
|
||||
<>
|
||||
{survey.status === "draft" ? (
|
||||
<div className="flex items-center">
|
||||
{(survey.type === "link" || environment.widgetSetupCompleted) && (
|
||||
<SurveyStatusIndicator status={survey.status} />
|
||||
)}
|
||||
{survey.status === "draft" && <p className="text-sm italic text-slate-600">Draft</p>}
|
||||
<p className="text-sm italic text-slate-600">Draft</p>
|
||||
</div>
|
||||
) : (
|
||||
<Select
|
||||
|
||||
@@ -217,29 +217,44 @@ export default function MultipleChoiceMultiForm({
|
||||
{question.choices &&
|
||||
question.choices.map((choice, choiceIdx) => (
|
||||
<div key={choiceIdx} className="inline-flex w-full items-center">
|
||||
<Input
|
||||
ref={choiceIdx === question.choices.length - 1 ? lastChoiceRef : null}
|
||||
id={choice.id}
|
||||
name={choice.id}
|
||||
value={choice.label}
|
||||
className={cn(choice.id === "other" && "border-dashed")}
|
||||
placeholder={choice.id === "other" ? "Other" : `Option ${choiceIdx + 1}`}
|
||||
onChange={(e) => updateChoice(choiceIdx, { label: e.target.value })}
|
||||
onBlur={() => {
|
||||
const duplicateLabel = findDuplicateLabel();
|
||||
if (duplicateLabel) {
|
||||
setisInvalidValue(duplicateLabel);
|
||||
} else if (findEmptyLabel()) {
|
||||
setisInvalidValue("");
|
||||
} else {
|
||||
setisInvalidValue(null);
|
||||
<div className="flex w-full space-x-2">
|
||||
<Input
|
||||
ref={choiceIdx === question.choices.length - 1 ? lastChoiceRef : null}
|
||||
id={choice.id}
|
||||
name={choice.id}
|
||||
value={choice.label}
|
||||
className={cn(choice.id === "other" && "border-dashed")}
|
||||
placeholder={choice.id === "other" ? "Other" : `Option ${choiceIdx + 1}`}
|
||||
onChange={(e) => updateChoice(choiceIdx, { label: e.target.value })}
|
||||
onBlur={() => {
|
||||
const duplicateLabel = findDuplicateLabel();
|
||||
if (duplicateLabel) {
|
||||
setisInvalidValue(duplicateLabel);
|
||||
} else if (findEmptyLabel()) {
|
||||
setisInvalidValue("");
|
||||
} else {
|
||||
setisInvalidValue(null);
|
||||
}
|
||||
}}
|
||||
isInvalid={
|
||||
(isInvalidValue === "" && choice.label.trim() === "") ||
|
||||
(isInvalidValue !== null && choice.label.trim() === isInvalidValue.trim())
|
||||
}
|
||||
}}
|
||||
isInvalid={
|
||||
(isInvalidValue === "" && choice.label.trim() === "") ||
|
||||
(isInvalidValue !== null && choice.label.trim() === isInvalidValue.trim())
|
||||
}
|
||||
/>
|
||||
/>
|
||||
{choice.id === "other" && (
|
||||
<Input
|
||||
id="otherInputLabel"
|
||||
name="otherInputLabel"
|
||||
value={question.otherOptionPlaceholder ?? "Please specify"}
|
||||
placeholder={question.otherOptionPlaceholder ?? "Please specify"}
|
||||
className={cn(choice.id === "other" && "border-dashed")}
|
||||
onChange={(e) => {
|
||||
if (e.target.value.trim() == "") e.target.value = "";
|
||||
updateQuestion(questionIdx, { otherOptionPlaceholder: e.target.value });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{question.choices && question.choices.length > 2 && (
|
||||
<TrashIcon
|
||||
className="ml-2 h-4 w-4 cursor-pointer text-slate-400 hover:text-slate-500"
|
||||
|
||||
@@ -216,30 +216,45 @@ export default function MultipleChoiceSingleForm({
|
||||
<div className="mt-2 space-y-2" id="choices">
|
||||
{question.choices &&
|
||||
question.choices.map((choice, choiceIdx) => (
|
||||
<div key={choiceIdx} className="inline-flex w-full items-center">
|
||||
<Input
|
||||
ref={choiceIdx === question.choices.length - 1 ? lastChoiceRef : null}
|
||||
id={choice.id}
|
||||
name={choice.id}
|
||||
value={choice.label}
|
||||
className={cn(choice.id === "other" && "border-dashed")}
|
||||
placeholder={choice.id === "other" ? "Other" : `Option ${choiceIdx + 1}`}
|
||||
onBlur={() => {
|
||||
const duplicateLabel = findDuplicateLabel();
|
||||
if (duplicateLabel) {
|
||||
setisInvalidValue(duplicateLabel);
|
||||
} else if (findEmptyLabel()) {
|
||||
setisInvalidValue("");
|
||||
} else {
|
||||
setisInvalidValue(null);
|
||||
<div key={choiceIdx} className="flex w-full items-center">
|
||||
<div className="flex w-full space-x-2">
|
||||
<Input
|
||||
ref={choiceIdx === question.choices.length - 1 ? lastChoiceRef : null}
|
||||
id={choice.id}
|
||||
name={choice.id}
|
||||
value={choice.label}
|
||||
className={cn(choice.id === "other" && "border-dashed")}
|
||||
placeholder={choice.id === "other" ? "Other" : `Option ${choiceIdx + 1}`}
|
||||
onBlur={() => {
|
||||
const duplicateLabel = findDuplicateLabel();
|
||||
if (duplicateLabel) {
|
||||
setisInvalidValue(duplicateLabel);
|
||||
} else if (findEmptyLabel()) {
|
||||
setisInvalidValue("");
|
||||
} else {
|
||||
setisInvalidValue(null);
|
||||
}
|
||||
}}
|
||||
onChange={(e) => updateChoice(choiceIdx, { label: e.target.value })}
|
||||
isInvalid={
|
||||
(isInvalidValue === "" && choice.label.trim() === "") ||
|
||||
(isInvalidValue !== null && choice.label.trim() === isInvalidValue.trim())
|
||||
}
|
||||
}}
|
||||
onChange={(e) => updateChoice(choiceIdx, { label: e.target.value })}
|
||||
isInvalid={
|
||||
(isInvalidValue === "" && choice.label.trim() === "") ||
|
||||
(isInvalidValue !== null && choice.label.trim() === isInvalidValue.trim())
|
||||
}
|
||||
/>
|
||||
/>
|
||||
{choice.id === "other" && (
|
||||
<Input
|
||||
id="otherInputLabel"
|
||||
name="otherInputLabel"
|
||||
value={question.otherOptionPlaceholder ?? "Please specify"}
|
||||
placeholder={question.otherOptionPlaceholder ?? "Please specify"}
|
||||
className={cn(choice.id === "other" && "border-dashed")}
|
||||
onChange={(e) => {
|
||||
if (e.target.value.trim() == "") e.target.value = "";
|
||||
updateQuestion(questionIdx, { otherOptionPlaceholder: e.target.value });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{question.choices && question.choices.length > 2 && (
|
||||
<TrashIcon
|
||||
className="ml-2 h-4 w-4 cursor-pointer text-slate-400 hover:text-slate-500"
|
||||
|
||||
@@ -257,12 +257,12 @@ export default function ResponseOptionsCard({
|
||||
surveyClosedMessage.subheading,
|
||||
]);
|
||||
|
||||
const handleCheckMark = () => {
|
||||
const toggleAutocomplete = () => {
|
||||
if (autoComplete) {
|
||||
const updatedSurvey = { ...localSurvey, autoComplete: null };
|
||||
setLocalSurvey(updatedSurvey);
|
||||
} else {
|
||||
const updatedSurvey = { ...localSurvey, autoComplete: 25 };
|
||||
const updatedSurvey = { ...localSurvey, autoComplete: Math.max(25, responseCount + 5) };
|
||||
setLocalSurvey(updatedSurvey);
|
||||
}
|
||||
};
|
||||
@@ -310,7 +310,7 @@ export default function ResponseOptionsCard({
|
||||
<AdvancedOptionToggle
|
||||
htmlId="closeOnNumberOfResponse"
|
||||
isChecked={autoComplete}
|
||||
onToggle={handleCheckMark}
|
||||
onToggle={toggleAutocomplete}
|
||||
title="Close survey on response limit"
|
||||
description="Automatically close the survey after a certain number of responses."
|
||||
childBorder={true}>
|
||||
@@ -411,7 +411,7 @@ export default function ResponseOptionsCard({
|
||||
htmlId="singleUserSurveyOptions"
|
||||
isChecked={!!localSurvey.singleUse?.enabled}
|
||||
onToggle={handleSingleUseSurveyToggle}
|
||||
title="Single-Use Survey Links"
|
||||
title="Single-use survey links"
|
||||
description="Allow only 1 response per survey link."
|
||||
childBorder={true}>
|
||||
<div className="flex w-full items-center space-x-1 p-4 pb-4">
|
||||
@@ -507,7 +507,7 @@ export default function ResponseOptionsCard({
|
||||
htmlId="protectSurveyWithPin"
|
||||
isChecked={isPinProtectionEnabled}
|
||||
onToggle={handleProtectSurveyWithPinToggle}
|
||||
title="Protect Survey with a PIN"
|
||||
title="Protect survey with a PIN"
|
||||
description="Only users who have the PIN can access the survey."
|
||||
childBorder={true}>
|
||||
<div className="flex w-full items-center space-x-1 p-4 pb-4">
|
||||
|
||||
@@ -223,14 +223,6 @@ export default function SurveyMenuBar({
|
||||
return false;
|
||||
}
|
||||
|
||||
/*
|
||||
Check whether the count for autocomplete responses is not less
|
||||
than the current count of accepted response and also it is not set to 0
|
||||
*/
|
||||
if ((survey.autoComplete && responseCount >= survey.autoComplete) || survey?.autoComplete === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
|
||||
@@ -40,9 +40,11 @@ export default function WhenToSendCard({
|
||||
const [isAddEventModalOpen, setAddEventModalOpen] = useState(false);
|
||||
const [activeIndex, setActiveIndex] = useState<number | null>(null);
|
||||
const [actionClasses, setActionClasses] = useState<TActionClass[]>(propActionClasses);
|
||||
const [randomizerToggle, setRandomizerToggle] = useState(localSurvey.displayPercentage ? true : false);
|
||||
const { isViewer } = getAccessFlags(membershipRole);
|
||||
|
||||
const autoClose = localSurvey.autoClose !== null;
|
||||
const delay = localSurvey.delay !== 0;
|
||||
|
||||
const addTriggerEvent = useCallback(() => {
|
||||
const updatedSurvey = { ...localSurvey };
|
||||
@@ -71,7 +73,7 @@ export default function WhenToSendCard({
|
||||
setLocalSurvey(updatedSurvey);
|
||||
};
|
||||
|
||||
const handleCheckMark = () => {
|
||||
const handleAutoCloseToggle = () => {
|
||||
if (autoClose) {
|
||||
const updatedSurvey = { ...localSurvey, autoClose: null };
|
||||
setLocalSurvey(updatedSurvey);
|
||||
@@ -81,6 +83,27 @@ export default function WhenToSendCard({
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelayToggle = () => {
|
||||
if (delay) {
|
||||
const updatedSurvey = { ...localSurvey, delay: 0 };
|
||||
setLocalSurvey(updatedSurvey);
|
||||
} else {
|
||||
const updatedSurvey = { ...localSurvey, delay: 5 };
|
||||
setLocalSurvey(updatedSurvey);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDisplayPercentageToggle = () => {
|
||||
if (localSurvey.displayPercentage) {
|
||||
const updatedSurvey = { ...localSurvey, displayPercentage: null };
|
||||
setLocalSurvey(updatedSurvey);
|
||||
} else {
|
||||
const updatedSurvey = { ...localSurvey, displayPercentage: 50 };
|
||||
setLocalSurvey(updatedSurvey);
|
||||
}
|
||||
setRandomizerToggle(!randomizerToggle);
|
||||
};
|
||||
|
||||
const handleInputSeconds = (e: any) => {
|
||||
let value = parseInt(e.target.value);
|
||||
|
||||
@@ -96,6 +119,11 @@ export default function WhenToSendCard({
|
||||
setLocalSurvey(updatedSurvey);
|
||||
};
|
||||
|
||||
const handleRandomizerInput = (e) => {
|
||||
const updatedSurvey = { ...localSurvey, displayPercentage: parseInt(e.target.value) };
|
||||
setLocalSurvey(updatedSurvey);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isAddEventModalOpen) return;
|
||||
if (activeIndex !== null) {
|
||||
@@ -155,8 +183,9 @@ export default function WhenToSendCard({
|
||||
</div>
|
||||
</div>
|
||||
</Collapsible.CollapsibleTrigger>
|
||||
<Collapsible.CollapsibleContent className="">
|
||||
<hr className="py-1 text-slate-600" />
|
||||
<hr className="py-1 text-slate-600" />
|
||||
|
||||
<Collapsible.CollapsibleContent className="p-3">
|
||||
{!isAddEventModalOpen &&
|
||||
localSurvey.triggers?.map((triggerEventClass, idx) => (
|
||||
<div className="mt-2" key={idx}>
|
||||
@@ -209,7 +238,14 @@ export default function WhenToSendCard({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="ml-2 flex items-center space-x-1 px-4 pb-4">
|
||||
<div className="ml-2 flex items-center space-x-1 px-4 pb-4"></div>
|
||||
<AdvancedOptionToggle
|
||||
htmlId="delay"
|
||||
isChecked={delay}
|
||||
onToggle={handleDelayToggle}
|
||||
title="Add delay before showing survey"
|
||||
description="Wait a few seconds after the trigger before showing the survey"
|
||||
childBorder={true}>
|
||||
<label
|
||||
htmlFor="triggerDelay"
|
||||
className="flex w-full cursor-pointer items-center rounded-lg border bg-slate-50 p-4">
|
||||
@@ -228,12 +264,11 @@ export default function WhenToSendCard({
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
</AdvancedOptionToggle>
|
||||
<AdvancedOptionToggle
|
||||
htmlId="autoClose"
|
||||
isChecked={autoClose}
|
||||
onToggle={handleCheckMark}
|
||||
onToggle={handleAutoCloseToggle}
|
||||
title="Auto close on inactivity"
|
||||
description="Automatically close the survey if the user does not respond after certain number of seconds"
|
||||
childBorder={true}>
|
||||
@@ -252,6 +287,30 @@ export default function WhenToSendCard({
|
||||
</p>
|
||||
</label>
|
||||
</AdvancedOptionToggle>
|
||||
<AdvancedOptionToggle
|
||||
htmlId="randomizer"
|
||||
isChecked={randomizerToggle}
|
||||
onToggle={handleDisplayPercentageToggle}
|
||||
title="Show survey to % of users"
|
||||
description="Only display the survey to a subset of the users"
|
||||
childBorder={true}>
|
||||
<div className="w-full">
|
||||
<div className="flex flex-col justify-center rounded-lg border bg-slate-50 p-6">
|
||||
<h3 className="mb-4 text-sm font-semibold text-slate-700">
|
||||
Show to {localSurvey.displayPercentage}% of targeted users
|
||||
</h3>
|
||||
<input
|
||||
id="small-range"
|
||||
type="range"
|
||||
min="1"
|
||||
max="100"
|
||||
value={localSurvey.displayPercentage ?? 50}
|
||||
onChange={handleRandomizerInput}
|
||||
className="range-sm mb-6 h-1 w-full cursor-pointer appearance-none rounded-lg bg-slate-200 dark:bg-slate-700"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</AdvancedOptionToggle>
|
||||
</Collapsible.CollapsibleContent>
|
||||
</Collapsible.Root>
|
||||
<AddNoCodeActionModal
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
import { UsageAttributesUpdater } from "@/app/(app)/components/FormbricksClient";
|
||||
import SurveyDropDownMenu from "@/app/(app)/environments/[environmentId]/surveys/components/SurveyDropDownMenu";
|
||||
import SurveyStarter from "@/app/(app)/environments/[environmentId]/surveys/components/SurveyStarter";
|
||||
import { generateSurveySingleUseId } from "@/app/lib/singleUseSurveys";
|
||||
import { ComputerDesktopIcon, LinkIcon, PlusIcon } from "@heroicons/react/24/solid";
|
||||
import { getServerSession } from "next-auth";
|
||||
import Link from "next/link";
|
||||
|
||||
import { authOptions } from "@formbricks/lib/authOptions";
|
||||
import { WEBAPP_URL } from "@formbricks/lib/constants";
|
||||
import { getEnvironment, getEnvironments } from "@formbricks/lib/environment/service";
|
||||
import { getMembershipByUserIdTeamId } from "@formbricks/lib/membership/service";
|
||||
import { getAccessFlags } from "@formbricks/lib/membership/utils";
|
||||
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
|
||||
import { getSurveys } from "@formbricks/lib/survey/service";
|
||||
import { getTeamByEnvironmentId } from "@formbricks/lib/team/service";
|
||||
import type { TEnvironment } from "@formbricks/types/environment";
|
||||
import { Badge } from "@formbricks/ui/Badge";
|
||||
import { SurveyStatusIndicator } from "@formbricks/ui/SurveyStatusIndicator";
|
||||
|
||||
export default async function SurveysList({ environmentId }: { environmentId: string }) {
|
||||
const session = await getServerSession(authOptions);
|
||||
const product = await getProductByEnvironmentId(environmentId);
|
||||
const team = await getTeamByEnvironmentId(environmentId);
|
||||
|
||||
if (!session) {
|
||||
throw new Error("Session not found");
|
||||
}
|
||||
|
||||
if (!product) {
|
||||
throw new Error("Product not found");
|
||||
}
|
||||
|
||||
if (!team) {
|
||||
throw new Error("Team not found");
|
||||
}
|
||||
|
||||
const currentUserMembership = await getMembershipByUserIdTeamId(session?.user.id, team.id);
|
||||
const { isViewer } = getAccessFlags(currentUserMembership?.role);
|
||||
const isSurveyCreationDeletionDisabled = isViewer;
|
||||
|
||||
const environment = await getEnvironment(environmentId);
|
||||
if (!environment) {
|
||||
throw new Error("Environment not found");
|
||||
}
|
||||
const surveys = await getSurveys(environmentId);
|
||||
|
||||
const environments: TEnvironment[] = await getEnvironments(product.id);
|
||||
const otherEnvironment = environments.find((e) => e.type !== environment.type)!;
|
||||
|
||||
if (surveys.length === 0) {
|
||||
return (
|
||||
<SurveyStarter
|
||||
environmentId={environmentId}
|
||||
environment={environment}
|
||||
product={product}
|
||||
user={session.user}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ul className="grid place-content-stretch gap-6 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-5 ">
|
||||
{!isSurveyCreationDeletionDisabled && (
|
||||
<Link href={`/environments/${environmentId}/surveys/templates`}>
|
||||
<li className="col-span-1 h-56">
|
||||
<div className="delay-50 flex h-full items-center justify-center overflow-hidden rounded-md bg-gradient-to-br from-slate-900 to-slate-800 font-light text-white shadow transition ease-in-out hover:scale-105 hover:from-slate-800 hover:to-slate-700">
|
||||
<div id="main-cta" className="px-4 py-8 sm:p-14 xl:p-10">
|
||||
<PlusIcon className="stroke-thin mx-auto h-14 w-14" />
|
||||
Create Survey
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</Link>
|
||||
)}
|
||||
{surveys
|
||||
.sort((a, b) => b.updatedAt?.getTime() - a.updatedAt?.getTime())
|
||||
.map((survey) => {
|
||||
const isSingleUse = survey.singleUse?.enabled ?? false;
|
||||
const isEncrypted = survey.singleUse?.isEncrypted ?? false;
|
||||
const singleUseId = isSingleUse ? generateSurveySingleUseId(isEncrypted) : undefined;
|
||||
return (
|
||||
<li key={survey.id} className="relative col-span-1 h-56">
|
||||
<div className="delay-50 flex h-full flex-col justify-between rounded-md bg-white shadow transition ease-in-out hover:scale-105">
|
||||
<div className="px-6 py-4">
|
||||
<Badge
|
||||
StartIcon={survey.type === "link" ? LinkIcon : ComputerDesktopIcon}
|
||||
startIconClassName="mr-2"
|
||||
text={
|
||||
survey.type === "link"
|
||||
? "Link Survey"
|
||||
: survey.type === "web"
|
||||
? "In-Product Survey"
|
||||
: ""
|
||||
}
|
||||
type="gray"
|
||||
size={"tiny"}
|
||||
className="font-base"></Badge>
|
||||
<p className="my-2 line-clamp-3 text-lg">{survey.name}</p>
|
||||
</div>
|
||||
<Link
|
||||
href={
|
||||
survey.status === "draft"
|
||||
? `/environments/${environmentId}/surveys/${survey.id}/edit`
|
||||
: `/environments/${environmentId}/surveys/${survey.id}/summary`
|
||||
}
|
||||
className="absolute h-full w-full"></Link>
|
||||
<div className="divide-y divide-slate-100">
|
||||
<div className="flex justify-between px-4 py-2 text-right sm:px-6">
|
||||
<div className="flex items-center">
|
||||
{survey.status !== "draft" && (
|
||||
<>
|
||||
{(survey.type === "link" || environment.widgetSetupCompleted) && (
|
||||
<SurveyStatusIndicator status={survey.status} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{survey.status === "draft" && (
|
||||
<span className="text-xs italic text-slate-400">Draft</span>
|
||||
)}
|
||||
</div>
|
||||
<SurveyDropDownMenu
|
||||
survey={survey}
|
||||
key={`surveys-${survey.id}`}
|
||||
environmentId={environmentId}
|
||||
environment={environment}
|
||||
otherEnvironment={otherEnvironment!}
|
||||
webAppUrl={WEBAPP_URL}
|
||||
singleUseId={singleUseId}
|
||||
isSurveyCreationDeletionDisabled={isSurveyCreationDeletionDisabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<UsageAttributesUpdater numSurveys={surveys.length} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -31,11 +31,12 @@ export default function SurveyStarter({
|
||||
setIsCreateSurveyLoading(true);
|
||||
const surveyType = environment?.widgetSetupCompleted ? "web" : "link";
|
||||
const autoComplete = surveyType === "web" ? 50 : null;
|
||||
const augmentedTemplate = {
|
||||
const augmentedTemplate: TSurveyInput = {
|
||||
...template.preset,
|
||||
type: surveyType,
|
||||
autoComplete,
|
||||
} as TSurveyInput;
|
||||
autoComplete: autoComplete || undefined,
|
||||
createdBy: user.id,
|
||||
};
|
||||
try {
|
||||
const survey = await createSurveyAction(environmentId, augmentedTemplate);
|
||||
router.push(`/environments/${environmentId}/surveys/${survey.id}/edit`);
|
||||
|
||||
@@ -1,18 +1,72 @@
|
||||
import WidgetStatusIndicator from "@/app/(app)/environments/[environmentId]/components/WidgetStatusIndicator";
|
||||
import SurveyStarter from "@/app/(app)/environments/[environmentId]/surveys/components/SurveyStarter";
|
||||
import { Metadata } from "next";
|
||||
import { getServerSession } from "next-auth";
|
||||
|
||||
import { authOptions } from "@formbricks/lib/authOptions";
|
||||
import { WEBAPP_URL } from "@formbricks/lib/constants";
|
||||
import { getEnvironment } from "@formbricks/lib/environment/service";
|
||||
import { getEnvironments } from "@formbricks/lib/environment/service";
|
||||
import { getMembershipByUserIdTeamId } from "@formbricks/lib/membership/service";
|
||||
import { getAccessFlags } from "@formbricks/lib/membership/utils";
|
||||
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
|
||||
import { getSurveys } from "@formbricks/lib/survey/service";
|
||||
import { getTeamByEnvironmentId } from "@formbricks/lib/team/service";
|
||||
import ContentWrapper from "@formbricks/ui/ContentWrapper";
|
||||
|
||||
import SurveysList from "./components/SurveyList";
|
||||
import SurveysList from "@formbricks/ui/SurveysList";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Your Surveys",
|
||||
};
|
||||
|
||||
export default async function SurveysPage({ params }) {
|
||||
const session = await getServerSession(authOptions);
|
||||
const product = await getProductByEnvironmentId(params.environmentId);
|
||||
const team = await getTeamByEnvironmentId(params.environmentId);
|
||||
if (!session) {
|
||||
throw new Error("Session not found");
|
||||
}
|
||||
|
||||
if (!product) {
|
||||
throw new Error("Product not found");
|
||||
}
|
||||
|
||||
if (!team) {
|
||||
throw new Error("Team not found");
|
||||
}
|
||||
|
||||
const currentUserMembership = await getMembershipByUserIdTeamId(session?.user.id, team.id);
|
||||
const { isViewer } = getAccessFlags(currentUserMembership?.role);
|
||||
|
||||
const environment = await getEnvironment(params.environmentId);
|
||||
if (!environment) {
|
||||
throw new Error("Environment not found");
|
||||
}
|
||||
const surveys = await getSurveys(params.environmentId);
|
||||
|
||||
const environments = await getEnvironments(product.id);
|
||||
const otherEnvironment = environments.find((e) => e.type !== environment.type)!;
|
||||
|
||||
return (
|
||||
<ContentWrapper className="flex h-full flex-col justify-between">
|
||||
<SurveysList environmentId={params.environmentId} />
|
||||
{surveys.length > 0 ? (
|
||||
<SurveysList
|
||||
environment={environment}
|
||||
surveys={surveys}
|
||||
otherEnvironment={otherEnvironment}
|
||||
isViewer={isViewer}
|
||||
WEBAPP_URL={WEBAPP_URL}
|
||||
userId={session.user.id}
|
||||
/>
|
||||
) : (
|
||||
<SurveyStarter
|
||||
environmentId={params.environmentId}
|
||||
environment={environment}
|
||||
product={product}
|
||||
user={session.user}
|
||||
/>
|
||||
)}
|
||||
{/* <SurveysList environmentId={params.environmentId} /> */}
|
||||
<WidgetStatusIndicator environmentId={params.environmentId} type="mini" />
|
||||
</ContentWrapper>
|
||||
);
|
||||
|
||||
@@ -70,11 +70,12 @@ export default function TemplateList({
|
||||
setLoading(true);
|
||||
const surveyType = environment?.widgetSetupCompleted ? "web" : "link";
|
||||
const autoComplete = surveyType === "web" ? 50 : null;
|
||||
const augmentedTemplate = {
|
||||
const augmentedTemplate: TSurveyInput = {
|
||||
...activeTemplate.preset,
|
||||
type: surveyType,
|
||||
autoComplete,
|
||||
} as TSurveyInput;
|
||||
createdBy: user.id,
|
||||
};
|
||||
const survey = await createSurveyAction(environmentId, augmentedTemplate);
|
||||
router.push(`/environments/${environmentId}/surveys/${survey.id}/edit`);
|
||||
};
|
||||
|
||||
@@ -2200,7 +2200,6 @@ export const templates: TTemplate[] = [
|
||||
},
|
||||
{
|
||||
name: "Improve Newsletter Content",
|
||||
|
||||
category: "Growth",
|
||||
objectives: ["increase_conversion", "sharpen_marketing_messaging"],
|
||||
description: "Find out how your subscribers like your newsletter content.",
|
||||
@@ -2506,6 +2505,7 @@ export const minimalSurvey: TSurvey = {
|
||||
name: "Minimal Survey",
|
||||
type: "web",
|
||||
environmentId: "someEnvId1",
|
||||
createdBy: null,
|
||||
status: "draft",
|
||||
attributeFilters: [],
|
||||
displayOption: "displayOnce",
|
||||
@@ -2522,6 +2522,7 @@ export const minimalSurvey: TSurvey = {
|
||||
enabled: false,
|
||||
},
|
||||
delay: 0, // No delay
|
||||
displayPercentage: null,
|
||||
autoComplete: null,
|
||||
closeOnDate: null,
|
||||
surveyClosedMessage: {
|
||||
|
||||
@@ -55,7 +55,7 @@ export default function Onboarding({ session, environmentId, user, product }: On
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const updatedProfile = { ...user, onboardingCompleted: true };
|
||||
const updatedProfile = { onboardingCompleted: true };
|
||||
await updateUserAction(updatedProfile);
|
||||
|
||||
if (environmentId) {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
|
||||
import CustomFilter from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter";
|
||||
import SurveyResultsTabs from "@/app/(app)/share/[sharingKey]/(analysis)/components/SurveyResultsTabs";
|
||||
import ResponseTimeline from "@/app/(app)/share/[sharingKey]/(analysis)/responses/components/ResponseTimeline";
|
||||
import CustomFilter from "@/app/(app)/share/[sharingKey]/components/CustomFilter";
|
||||
import SummaryHeader from "@/app/(app)/share/[sharingKey]/components/SummaryHeader";
|
||||
import { getFilterResponses } from "@/app/lib/surveys/surveys";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
|
||||
@@ -4,8 +4,8 @@ import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/comp
|
||||
import SummaryDropOffs from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryDropOffs";
|
||||
import SummaryList from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryList";
|
||||
import SummaryMetadata from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryMetadata";
|
||||
import CustomFilter from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter";
|
||||
import SurveyResultsTabs from "@/app/(app)/share/[sharingKey]/(analysis)/components/SurveyResultsTabs";
|
||||
import CustomFilter from "@/app/(app)/share/[sharingKey]/components/CustomFilter";
|
||||
import SummaryHeader from "@/app/(app)/share/[sharingKey]/components/SummaryHeader";
|
||||
import { getFilterResponses } from "@/app/lib/surveys/surveys";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
|
||||
@@ -168,8 +168,8 @@ const CustomFilter = ({ environmentTags, responses, survey, totalResponses }: Cu
|
||||
"Response ID": response.id,
|
||||
Timestamp: response.createdAt,
|
||||
Finished: response.finished,
|
||||
"User ID": response.person?.userId,
|
||||
"Survey ID": response.surveyId,
|
||||
"Formbricks User ID": response.person?.id ?? "",
|
||||
};
|
||||
const metaDataKeys = extracMetadataKeys(response.meta);
|
||||
let metaData = {};
|
||||
@@ -216,7 +216,7 @@ const CustomFilter = ({ environmentTags, responses, survey, totalResponses }: Cu
|
||||
"Timestamp",
|
||||
"Finished",
|
||||
"Survey ID",
|
||||
"Formbricks User ID",
|
||||
"User ID",
|
||||
...metaDataFields,
|
||||
...questionNames,
|
||||
...(hiddenFieldIds ?? []),
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
|
||||
const ContentLayout = ({ headline, description, children }) => {
|
||||
interface ContentLayoutProps {
|
||||
headline: string;
|
||||
description: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
const ContentLayout = ({ headline, description, children }: ContentLayoutProps) => {
|
||||
return (
|
||||
<div className="flex h-screen">
|
||||
<div className="m-auto flex flex-col gap-7 text-center text-slate-700">
|
||||
@@ -57,9 +63,17 @@ export const ExpiredContent = () => {
|
||||
return (
|
||||
<ContentLayout
|
||||
headline="Invite expired 😥"
|
||||
description="Invites are valid for 7 days. Please request a new invite.">
|
||||
<div></div>
|
||||
</ContentLayout>
|
||||
description="Invites are valid for 7 days. Please request a new invite."
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const InvitationNotFound = () => {
|
||||
return (
|
||||
<ContentLayout
|
||||
headline="Invite not found 😥"
|
||||
description="The invitation code cannot be found or has already been used."
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,48 +1,54 @@
|
||||
import { getServerSession } from "next-auth";
|
||||
|
||||
import { authOptions } from "@formbricks/lib/authOptions";
|
||||
import { WEBAPP_URL } from "@formbricks/lib/constants";
|
||||
import { sendInviteAcceptedEmail } from "@formbricks/lib/emails/emails";
|
||||
import { env } from "@formbricks/lib/env.mjs";
|
||||
import { deleteInvite, getInvite } from "@formbricks/lib/invite/service";
|
||||
import { verifyInviteToken } from "@formbricks/lib/jwt";
|
||||
import { createMembership } from "@formbricks/lib/membership/service";
|
||||
|
||||
import {
|
||||
ExpiredContent,
|
||||
InvitationNotFound,
|
||||
NotLoggedInContent,
|
||||
RightAccountContent,
|
||||
UsedContent,
|
||||
WrongAccountContent,
|
||||
} from "./components/InviteContentComponents";
|
||||
|
||||
export default async function JoinTeam({ searchParams }) {
|
||||
const currentUser = await getServerSession(authOptions);
|
||||
export default async function InvitePage({ searchParams }) {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
try {
|
||||
const { inviteId, email } = verifyInviteToken(searchParams.token);
|
||||
|
||||
const invite = await getInvite(inviteId);
|
||||
|
||||
if (!invite) {
|
||||
return <InvitationNotFound />;
|
||||
}
|
||||
|
||||
const isInviteExpired = new Date(invite.expiresAt) < new Date();
|
||||
|
||||
if (!invite || isInviteExpired) {
|
||||
if (isInviteExpired) {
|
||||
return <ExpiredContent />;
|
||||
} else if (invite.accepted) {
|
||||
return <UsedContent />;
|
||||
} else if (!currentUser) {
|
||||
const redirectUrl = env.NEXTAUTH_URL + "/invite?token=" + searchParams.token;
|
||||
} else if (!session) {
|
||||
const redirectUrl = WEBAPP_URL + "/invite?token=" + searchParams.token;
|
||||
return <NotLoggedInContent email={email} token={searchParams.token} redirectUrl={redirectUrl} />;
|
||||
} else if (currentUser.user?.email !== email) {
|
||||
} else if (session.user?.email !== email) {
|
||||
return <WrongAccountContent />;
|
||||
} else {
|
||||
await createMembership(invite.teamId, currentUser.user.id, { accepted: true, role: invite.role });
|
||||
await createMembership(invite.teamId, session.user.id, { accepted: true, role: invite.role });
|
||||
await deleteInvite(inviteId);
|
||||
|
||||
sendInviteAcceptedEmail(invite.creator.name ?? "", currentUser.user?.name ?? "", invite.creator.email);
|
||||
sendInviteAcceptedEmail(invite.creator.name ?? "", session.user?.name ?? "", invite.creator.email);
|
||||
|
||||
return <RightAccountContent />;
|
||||
}
|
||||
} catch (e) {
|
||||
return <ExpiredContent />;
|
||||
console.error(e);
|
||||
return <InvitationNotFound />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,30 +159,27 @@ const createSurveyFields = (surveyResponses: SurveyResponse[]) => {
|
||||
return surveyFields;
|
||||
};
|
||||
|
||||
const notificationFooter = () => {
|
||||
const notificationFooter = (environmentId: string) => {
|
||||
return `
|
||||
<p style="margin-bottom:0px; padding-top:1em; font-weight:500">All the best,</p>
|
||||
<p style="margin-top:0px;">The Formbricks Team 🤍</p>
|
||||
<div style="margin-top:0.8em; background-color:#f1f5f9; border-radius:8px; padding:0.01em 1.6em; text-align:center; font-size:0.8em; line-height:1.2em;"><p><i>This is a Beta feature. If you experience any issues, please let us know by replying to this email 🙏</i></p></div>
|
||||
`;
|
||||
<p style="margin-bottom:0px; padding-top:1em; font-weight:500">All the best,</p>
|
||||
<p style="margin-top:0px;">The Formbricks Team 🤍</p>
|
||||
<div style="margin-top:0.8em; background-color:#f1f5f9; border-radius:8px; padding:0.01em 1.6em; text-align:center; font-size:0.8em; line-height:1.2em;">
|
||||
<p><i>To halt Weekly Updates, <a href="${WEBAPP_URL}/environments/${environmentId}/settings/notifications">please turn them off</a> in your settings 🙏</i></p>
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
|
||||
const createReminderNotificationBody = (notificationData: NotificationResponse, webUrl) => {
|
||||
const createReminderNotificationBody = (notificationData: NotificationResponse) => {
|
||||
return `
|
||||
<p>We’d love to send you a Weekly Summary, but currently there are no surveys running for ${notificationData.productName}.</p>
|
||||
|
||||
<p style="font-weight: bold; padding-top:1em;">Don’t let a week pass without learning about your users:</p>
|
||||
|
||||
<a class="button" href="${webUrl}/environments/${notificationData.environmentId}/surveys?utm_source=weekly&utm_medium=email&utm_content=SetupANewSurveyCTA">Setup a new survey</a>
|
||||
|
||||
<a class="button" href="${WEBAPP_URL}/environments/${notificationData.environmentId}/surveys?utm_source=weekly&utm_medium=email&utm_content=SetupANewSurveyCTA">Setup a new survey</a>
|
||||
|
||||
<br/>
|
||||
<p style="padding-top:1em;">Need help finding the right survey for your product? Pick a 15-minute slot <a href="https://cal.com/johannes/15">in our CEOs calendar</a> or reply to this email :)</p>
|
||||
|
||||
|
||||
<p style="margin-bottom:0px; padding-top:1em; font-weight:500">All the best,</p>
|
||||
<p style="margin-top:0px;">The Formbricks Team</p>
|
||||
|
||||
<div style="margin-top:0.8em; background-color:#f1f5f9; border-radius:99px; margin:1em; padding:0.01em 1.6em; text-align:center;"><p><i>This is a Beta feature. If you experience any issues, please let us know by replying to this email 🙏</i></p></div>
|
||||
${notificationFooter(notificationData.environmentId)}
|
||||
`;
|
||||
};
|
||||
|
||||
@@ -207,7 +204,7 @@ export const sendWeeklySummaryNotificationEmail = async (
|
||||
${notificationHeader(notificationData.productName, startDate, endDate, startYear, endYear)}
|
||||
${notificationInsight(notificationData.insights)}
|
||||
${notificationLiveSurveys(notificationData.surveys, notificationData.environmentId)}
|
||||
${notificationFooter()}
|
||||
${notificationFooter(notificationData.environmentId)}
|
||||
`),
|
||||
});
|
||||
};
|
||||
@@ -231,7 +228,7 @@ export const sendNoLiveSurveyNotificationEmail = async (
|
||||
subject: getEmailSubject(notificationData.productName),
|
||||
html: withEmailTemplate(`
|
||||
${notificationHeader(notificationData.productName, startDate, endDate, startYear, endYear)}
|
||||
${createReminderNotificationBody(notificationData, WEBAPP_URL)}
|
||||
${createReminderNotificationBody(notificationData)}
|
||||
`),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@ import { prisma } from "@formbricks/database";
|
||||
import { INTERNAL_SECRET } from "@formbricks/lib/constants";
|
||||
import { sendResponseFinishedEmail } from "@formbricks/lib/emails/emails";
|
||||
import { getIntegrations } from "@formbricks/lib/integration/service";
|
||||
import { getResponseCountBySurveyId } from "@formbricks/lib/response/service";
|
||||
import { getSurvey, updateSurvey } from "@formbricks/lib/survey/service";
|
||||
import { convertDatesInObject } from "@formbricks/lib/time";
|
||||
import { ZPipelineInput } from "@formbricks/types/pipelines";
|
||||
@@ -125,6 +126,9 @@ export async function POST(request: Request) {
|
||||
return false;
|
||||
});
|
||||
|
||||
// Exclude current response
|
||||
const responseCount = await getResponseCountBySurveyId(surveyId);
|
||||
|
||||
if (usersWithNotifications.length > 0) {
|
||||
// get survey
|
||||
if (!surveyData) {
|
||||
@@ -155,7 +159,7 @@ export async function POST(request: Request) {
|
||||
// send email to all users
|
||||
await Promise.all(
|
||||
usersWithNotifications.map(async (user) => {
|
||||
await sendResponseFinishedEmail(user.email, environmentId, survey, response);
|
||||
await sendResponseFinishedEmail(user.email, environmentId, survey, response, responseCount);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,7 +23,11 @@ export async function POST(request: Request): Promise<NextResponse> {
|
||||
responseInput.personId = null;
|
||||
}
|
||||
const agent = UAParser(request.headers.get("user-agent"));
|
||||
const country = headers().get("CF-IPCountry") || headers().get("X-Vercel-IP-Country") || undefined;
|
||||
const country =
|
||||
headers().get("CF-IPCountry") ||
|
||||
headers().get("X-Vercel-IP-Country") ||
|
||||
headers().get("CloudFront-Viewer-Country") ||
|
||||
undefined;
|
||||
const inputValidation = ZResponseLegacyInput.safeParse(responseInput);
|
||||
|
||||
if (!inputValidation.success) {
|
||||
|
||||
@@ -46,7 +46,11 @@ export async function POST(request: Request, context: Context): Promise<NextResp
|
||||
}
|
||||
|
||||
const agent = UAParser(request.headers.get("user-agent"));
|
||||
const country = headers().get("CF-IPCountry") || headers().get("X-Vercel-IP-Country") || undefined;
|
||||
const country =
|
||||
headers().get("CF-IPCountry") ||
|
||||
headers().get("X-Vercel-IP-Country") ||
|
||||
headers().get("CloudFront-Viewer-Country") ||
|
||||
undefined;
|
||||
const inputValidation = ZResponseInput.safeParse({ ...responseInput, environmentId });
|
||||
|
||||
if (!inputValidation.success) {
|
||||
|
||||
@@ -2,18 +2,14 @@ import { NextResponse } from "next/server";
|
||||
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { EMAIL_VERIFICATION_DISABLED, INVITE_DISABLED, SIGNUP_ENABLED } from "@formbricks/lib/constants";
|
||||
import {
|
||||
sendGettingStartedEmail,
|
||||
sendInviteAcceptedEmail,
|
||||
sendVerificationEmail,
|
||||
} from "@formbricks/lib/emails/emails";
|
||||
import { sendInviteAcceptedEmail, sendVerificationEmail } from "@formbricks/lib/emails/emails";
|
||||
import { env } from "@formbricks/lib/env.mjs";
|
||||
import { deleteInvite } from "@formbricks/lib/invite/service";
|
||||
import { verifyInviteToken } from "@formbricks/lib/jwt";
|
||||
import { createMembership } from "@formbricks/lib/membership/service";
|
||||
import { createProduct } from "@formbricks/lib/product/service";
|
||||
import { createTeam, getTeam } from "@formbricks/lib/team/service";
|
||||
import { createUser } from "@formbricks/lib/user/service";
|
||||
import { createUser, updateUser } from "@formbricks/lib/user/service";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
let { inviteToken, ...user } = await request.json();
|
||||
@@ -54,8 +50,6 @@ export async function POST(request: Request) {
|
||||
|
||||
if (!EMAIL_VERIFICATION_DISABLED) {
|
||||
await sendVerificationEmail(user);
|
||||
} else {
|
||||
await sendGettingStartedEmail(user);
|
||||
}
|
||||
|
||||
await sendInviteAcceptedEmail(invite.creator.name, user.name, invite.creator.email);
|
||||
@@ -82,14 +76,28 @@ export async function POST(request: Request) {
|
||||
else {
|
||||
const team = await createTeam({ name: user.name + "'s Team" });
|
||||
await createMembership(team.id, user.id, { role: "owner", accepted: true });
|
||||
await createProduct(team.id, { name: "My Product" });
|
||||
const product = await createProduct(team.id, { name: "My Product" });
|
||||
|
||||
const updatedNotificationSettings = {
|
||||
...user.notificationSettings,
|
||||
alert: {
|
||||
...user.notificationSettings?.alert,
|
||||
},
|
||||
weeklySummary: {
|
||||
...user.notificationSettings?.weeklySummary,
|
||||
[product.id]: true,
|
||||
},
|
||||
};
|
||||
|
||||
await updateUser(user.id, {
|
||||
notificationSettings: updatedNotificationSettings,
|
||||
});
|
||||
}
|
||||
// send verification email amd return user
|
||||
if (!EMAIL_VERIFICATION_DISABLED) {
|
||||
await sendVerificationEmail(user);
|
||||
} else {
|
||||
await sendGettingStartedEmail(user);
|
||||
}
|
||||
|
||||
return NextResponse.json(user);
|
||||
} catch (e) {
|
||||
if (e.code === "P2002") {
|
||||
|
||||
@@ -111,3 +111,20 @@ input[type="search"]::-ms-clear {
|
||||
input[type="search"]::-ms-reveal {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.surveyFilterDropdown[data-state="open"]{
|
||||
background-color: #0f172a;
|
||||
color: white;
|
||||
}
|
||||
.surveyFilterDropdown:hover * {
|
||||
background-color: #0f172a;
|
||||
color: white;
|
||||
}
|
||||
|
||||
input[type='range']::-webkit-slider-thumb {
|
||||
background: #0f172a;
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
border-radius: 50%;
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
@@ -13,3 +13,6 @@ export const shareUrlRoute = (url: string): boolean => {
|
||||
const regex = /\/share\/[A-Za-z0-9]+\/(summary|responses)/;
|
||||
return regex.test(url);
|
||||
};
|
||||
|
||||
export const isWebAppRoute = (url: string): boolean =>
|
||||
url.startsWith("/environments") && url !== "/api/auth/signout";
|
||||
|
||||
@@ -15,7 +15,11 @@ export default async function Home() {
|
||||
redirect("/auth/login");
|
||||
}
|
||||
|
||||
if (!ONBOARDING_DISABLED && session?.user && !session?.user?.onboardingCompleted) {
|
||||
if (!session?.user) {
|
||||
return <ClientLogout />;
|
||||
}
|
||||
|
||||
if (!ONBOARDING_DISABLED && !session.user.onboardingCompleted) {
|
||||
return redirect(`/onboarding`);
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ export default function LegalFooter({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`fixed bottom-0 h-12 w-full`}
|
||||
className={`absolute bottom-0 h-12 w-full`}
|
||||
style={{
|
||||
backgroundColor: `${bgColor}`,
|
||||
}}>
|
||||
|
||||
@@ -135,7 +135,7 @@ export default function LinkSurvey({
|
||||
|
||||
return (
|
||||
<>
|
||||
<ContentWrapper className="h-full w-full p-0 md:max-w-md">
|
||||
<ContentWrapper className="my-12 h-full w-full p-0 md:max-w-md">
|
||||
{isPreview && (
|
||||
<div className="fixed left-0 top-0 flex w-full items-center justify-between bg-slate-600 p-2 px-4 text-center text-sm text-white shadow-sm">
|
||||
<div />
|
||||
|
||||
@@ -195,7 +195,7 @@ export default async function LinkSurveyPage({ params, searchParams }: LinkSurve
|
||||
}
|
||||
|
||||
return survey ? (
|
||||
<div>
|
||||
<div className="relative">
|
||||
<MediaBackground survey={survey}>
|
||||
<LinkSurvey
|
||||
survey={survey}
|
||||
|
||||
@@ -6,14 +6,33 @@ import {
|
||||
} from "@/app/middleware/bucket";
|
||||
import {
|
||||
clientSideApiRoute,
|
||||
isWebAppRoute,
|
||||
loginRoute,
|
||||
shareUrlRoute,
|
||||
signupRoute,
|
||||
} from "@/app/middleware/endpointValidator";
|
||||
import { getToken } from "next-auth/jwt";
|
||||
import { NextResponse } from "next/server";
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
import { WEBAPP_URL } from "@formbricks/lib/constants";
|
||||
|
||||
export async function middleware(request: NextRequest) {
|
||||
const token = await getToken({ req: request });
|
||||
|
||||
if (isWebAppRoute(request.nextUrl.pathname) && !token) {
|
||||
const loginUrl = new URL(
|
||||
`/auth/login?callbackUrl=${encodeURIComponent(request.nextUrl.toString())}`,
|
||||
WEBAPP_URL
|
||||
);
|
||||
return NextResponse.redirect(loginUrl.href);
|
||||
}
|
||||
|
||||
const callbackUrl = request.nextUrl.searchParams.get("callbackUrl");
|
||||
if (token && callbackUrl) {
|
||||
return NextResponse.redirect(WEBAPP_URL + callbackUrl);
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
return NextResponse.next();
|
||||
}
|
||||
@@ -54,5 +73,8 @@ export const config = {
|
||||
"/api/v1/js/actions",
|
||||
"/api/v1/client/storage",
|
||||
"/share/(.*)/:path",
|
||||
"/environments/:path*",
|
||||
"/api/auth/signout",
|
||||
"/auth/login",
|
||||
],
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@formbricks/web",
|
||||
"version": "1.5.0",
|
||||
"version": "1.5.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"clean": "rimraf .turbo node_modules .next",
|
||||
@@ -27,9 +27,9 @@
|
||||
"@radix-ui/react-collapsible": "^1.0.3",
|
||||
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
||||
"@react-email/components": "^0.0.14",
|
||||
"@sentry/nextjs": "^7.98.0",
|
||||
"@sentry/nextjs": "^7.99.0",
|
||||
"@vercel/og": "^0.6.2",
|
||||
"@vercel/speed-insights": "^1.0.8",
|
||||
"@vercel/speed-insights": "^1.0.9",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"dotenv": "^16.4.1",
|
||||
"encoding": "^0.1.13",
|
||||
@@ -37,25 +37,25 @@
|
||||
"googleapis": "^131.0.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"lodash": "^4.17.21",
|
||||
"lru-cache": "^10.1.0",
|
||||
"lucide-react": "^0.315.0",
|
||||
"lru-cache": "^10.2.0",
|
||||
"lucide-react": "^0.321.0",
|
||||
"mime": "^4.0.1",
|
||||
"next": "14.1.0",
|
||||
"nodemailer": "^6.9.8",
|
||||
"nodemailer": "^6.9.9",
|
||||
"otplib": "^12.0.1",
|
||||
"posthog-js": "^1.102.1",
|
||||
"posthog-js": "^1.104.4",
|
||||
"prismjs": "^1.29.0",
|
||||
"qrcode": "^1.5.3",
|
||||
"react": "18.2.0",
|
||||
"react-beautiful-dnd": "^13.1.1",
|
||||
"react-dom": "18.2.0",
|
||||
"react-email": "^2.0.0",
|
||||
"react-hook-form": "^7.49.3",
|
||||
"react-hook-form": "^7.50.0",
|
||||
"react-hot-toast": "^2.4.1",
|
||||
"react-icons": "^5.0.1",
|
||||
"sharp": "^0.33.2",
|
||||
"ua-parser-js": "^1.0.37",
|
||||
"webpack": "^5.90.0",
|
||||
"webpack": "^5.90.1",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -63,13 +63,14 @@ test.describe("JS Package Test", async () => {
|
||||
|
||||
// Formbricks Modal is visible
|
||||
await expect(page.getByRole("link", { name: "Powered by Formbricks" })).toBeVisible();
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
});
|
||||
|
||||
test("Admin checks Display", async ({ page }) => {
|
||||
await login(page, email, password);
|
||||
|
||||
await page.locator("li").filter({ hasText: "In-Product SurveyProduct" }).getByRole("link").click();
|
||||
|
||||
await page.getByRole("link", { name: "In-app Open options Product" }).click();
|
||||
(await page.waitForSelector("text=Responses")).isVisible();
|
||||
|
||||
// Survey should have 1 Display
|
||||
@@ -120,8 +121,7 @@ test.describe("JS Package Test", async () => {
|
||||
test("Admin validates Response", async ({ page }) => {
|
||||
await login(page, email, password);
|
||||
|
||||
await page.locator("li").filter({ hasText: "In-Product SurveyProduct" }).getByRole("link").click();
|
||||
|
||||
await page.getByRole("link", { name: "In-app Open options Product" }).click();
|
||||
(await page.waitForSelector("text=Responses")).isVisible();
|
||||
|
||||
// Survey should have 2 Displays
|
||||
|
||||
@@ -40,6 +40,7 @@ export const skipOnboarding = async (page: Page): Promise<void> => {
|
||||
await page.waitForURL("/onboarding");
|
||||
await expect(page).toHaveURL("/onboarding");
|
||||
await page.getByRole("button", { name: "I'll do it later" }).click();
|
||||
await page.waitForTimeout(500);
|
||||
await page.getByRole("button", { name: "I'll do it later" }).click();
|
||||
await page.waitForURL(/\/environments\/[^/]+\/surveys/);
|
||||
await expect(page).toHaveURL(/\/environments\/[^/]+\/surveys/);
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
Forget all previous instructions. You are the coding assistant "Formbricks Assistant".
|
||||
|
||||
- You help programmers find a solution to their questions and coding challenges. You give very brief and concise answers unless I the user prompt you to elaborate.
|
||||
- Formbricks is the open-source go-to solution for in-product micro-surveys that is supercharging our users product experience!
|
||||
- Formbricks uses Typescript, Next.Js, Next-auth, Prisma, TailwindCss, Radix UI
|
||||
- When you are asked to generate documentation please have a playful but succinct writing style and return everything in escaped markdown.
|
||||
- This is the prisma schema:
|
||||
enum PipelineTriggers { responseCreated, responseUpdated, responseFinished }
|
||||
model Webhook { id String @id @default(cuid()), createdAt DateTime @default(now()) @map(name: "created_at"), updatedAt DateTime @updatedAt @map(name: "updated_at"), url String, environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade), environmentId String, triggers PipelineTriggers[] }
|
||||
model Attribute { id String @id @default(cuid()), createdAt DateTime @default(now()) @map(name: "created_at"), updatedAt DateTime @updatedAt @map(name: "updated_at"), attributeClass AttributeClass @relation(fields: [attributeClassId], references: [id], onDelete: Cascade), attributeClassId String, person Person @relation(fields: [personId], references: [id], onDelete: Cascade), personId String, value String, @@unique([attributeClassId, personId]) }
|
||||
enum AttributeType { code, noCode, automatic }
|
||||
model AttributeClass { id String @id @default(cuid()), createdAt DateTime @default(now()) @map(name: "created_at"), updatedAt DateTime @updatedAt @map(name: "updated_at"), name String, description String?, type AttributeType, environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade), environmentId String, attributes Attribute[], attributeFilters SurveyAttributeFilter[], @@unique([name, environmentId]) }
|
||||
model Person { id String @id @default(cuid()), createdAt DateTime @default(now()) @map(name: "created_at"), updatedAt DateTime @updatedAt @map(name: "updated_at"), environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade), environmentId String, responses Response[], sessions Session[], attributes Attribute[], displays Display[] }
|
||||
model Response { id String @id @default(cuid()), createdAt DateTime @default(now()) @map(name: "created_at"), updatedAt DateTime @updatedAt @map(name: "updated_at"), finished Boolean @default(false), survey Survey @relation(fields: [surveyId], references: [id], onDelete: Cascade), surveyId String, person Person? @relation(fields: [personId], references: [id], onDelete: Cascade), personId String?, data Json @default("{}"), meta Json @default("{}") }
|
||||
enum SurveyStatus { draft, inProgress, paused, completed, archived }
|
||||
enum DisplayStatus { seen, responded }
|
||||
model Display { id String @id @default(cuid()), createdAt DateTime @default(now()) @map(name: "created_at"), updatedAt DateTime @updatedAt @map(name: "updated_at"), survey Survey @relation(fields: [surveyId], references: [id], onDelete: Cascade), surveyId String, person Person? @relation(fields: [personId], references: [id], onDelete: Cascade), personId String?, status DisplayStatus @default(seen) }
|
||||
model SurveyTrigger { id String @id @default(cuid()), createdAt DateTime @default(now()) @map(name: "created_at"), updatedAt DateTime @updatedAt @map(name: "updated_at"), survey Survey @relation(fields: [surveyId], references: [id], onDelete: Cascade), surveyId String, eventClass EventClass @relation(fields: [eventClassId], references: [id], onDelete: Cascade), eventClassId String, @@unique([surveyId, eventClassId]) }
|
||||
enum SurveyAttributeFilterCondition { equals, notEquals }
|
||||
model SurveyAttributeFilter { id String @id @default(cuid()), createdAt DateTime @default(now()) @map(name: "created_at"), updatedAt DateTime @updatedAt @map(name: "updated_at"), attributeClass AttributeClass @relation(fields: [attributeClassId], references: [id], onDelete: Cascade), attributeClassId String, survey Survey @relation(fields: [surveyId], references: [id], onDelete: Cascade), surveyId String, condition SurveyAttributeFilterCondition, value String, @@unique([surveyId, attributeClassId]) }
|
||||
enum SurveyType { email, link, mobile, web }
|
||||
enum displayOptions { displayOnce, displayMultiple, respondMultiple }
|
||||
model Survey { id String @id @default(cuid()), createdAt DateTime @default(now()) @map(name: "created_at"), updatedAt DateTime @updatedAt @map(name: "updated_at"), name String, type SurveyType @default(web), environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade), environmentId String, status SurveyStatus @default(draft), questions Json @default("[]"), thankYouCard Json @default("{"enabled": false}"), responses Response[], displayOption displayOptions @default(displayOnce), recontactDays Int?, triggers SurveyTrigger[], attributeFilters SurveyAttributeFilter[], displays Display[], autoClose Int?, delay Int @default(0) }
|
||||
model Event { id String @id @default(cuid()), createdAt DateTime @default(now()) @map(name: "created_at"), eventClass EventClass? @relation(fields: [eventClassId], references: [id]), eventClassId String?, session Session @relation(fields: [sessionId], references: [id], onDelete: Cascade), sessionId String, properties Json @default("{}") }
|
||||
enum EventType { code, noCode, automatic }
|
||||
model EventClass { id String @id @default(cuid()), createdAt DateTime @default(now()) @map(name: "created_at"), updatedAt DateTime @updatedAt @map(name: "updated_at"), name String, description String?, type EventType, events Event[], noCodeConfig Json?, environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade), environmentId String, surveys SurveyTrigger[], @@unique([name, environmentId]) }
|
||||
model Session { id String @id @default(cuid()), createdAt DateTime @default(now()) @map(name: "created_at"), updatedAt DateTime @updatedAt @map(name: "updated_at"), person Person @relation(fields: [personId], references: [id], onDelete: Cascade), personId String, events Event[] }
|
||||
enum EnvironmentType { production, development }
|
||||
model Environment { id String @id @default(cuid()), createdAt DateTime @default(now()) @map(name: "created_at"), updatedAt DateTime @updatedAt @map(name: "updated_at"), type EnvironmentType, product Product @relation(fields: [productId], references: [id], onDelete: Cascade), productId String, widgetSetupCompleted Boolean @default(false), surveys Survey[], people Person[], eventClasses EventClass[], attributeClasses AttributeClass[], apiKeys ApiKey[], webhooks Webhook[] }
|
||||
model Product { id String @id @default(cuid()), createdAt DateTime @default(now()) @map(name: "created_at"), updatedAt DateTime @updatedAt @map(name: "updated_at"), name String, team Team @relation(fields: [teamId], references: [id], onDelete: Cascade), teamId String, environments Environment[], brandColor String @default("#64748b"), recontactDays Int @default(7), formbricksSignature Boolean @default(true) }
|
||||
enum Plan { free, pro }
|
||||
model Team { id String @id @default(cuid()), createdAt DateTime @default(now()) @map(name: "created_at"), updatedAt DateTime @updatedAt @map(name: "updated_at"), name String, memberships Membership[], products Product[], plan Plan @default(free), stripeCustomerId String?, invites Invite[] }
|
||||
enum MembershipRole { owner, admin, editor, developer, viewer }
|
||||
model Membership { team Team @relation(fields: [teamId], references: [id], onDelete: Cascade), teamId String, user User @relation(fields: [userId], references: [id], onDelete: Cascade), userId String, accepted Boolean @default(false), role MembershipRole, @@id([userId, teamId]) }
|
||||
model Invite { id String @id @default(uuid()), email String, name String?, team Team @relation(fields: [teamId], references: [id], onDelete: Cascade), teamId String, creator User @relation("inviteCreatedBy", fields: [creatorId], references: [id]), creatorId String, acceptor User? @relation("inviteAcceptedBy", fields: [acceptorId], references: [id], onDelete: Cascade), acceptorId String?, accepted Boolean @default(false), createdAt DateTime @default(now()), expiresAt DateTime, role MembershipRole @default(admin), @@index([email, teamId], name: "email_teamId_unique") }
|
||||
model ApiKey { id String @id @unique @default(cuid()), createdAt DateTime @default(now()), lastUsedAt DateTime?, label String?, hashedKey String @unique(), environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade), environmentId String }
|
||||
enum IdentityProvider { email, github, google }
|
||||
model Account { id String @id @default(cuid()), createdAt DateTime @default(now()) @map(name: "created_at"), updatedAt DateTime @updatedAt @map(name: "updated_at"), user User? @relation(fields: [userId], references: [id], onDelete: Cascade), userId String, type String, provider String, providerAccountId String, access_token String? @db.Text, refresh_token String? @db.Text, expires_at Int?, token_type String?, scope String?, id_token String? @db.Text, session_state String?, @@unique([provider, providerAccountId]) }
|
||||
enum Role { project_manager, engineer, founder, marketing_specialist, other }
|
||||
enum Objective { increase_conversion, improve_user_retention, increase_user_adoption, sharpen_marketing_messaging, support_sales, other }
|
||||
enum Intention { survey_user_segments, survey_at_specific_point_in_user_journey, enrich_customer_profiles, collect_all_user_feedback_on_one_platform, other }
|
||||
model User { id String @id @default(cuid()), createdAt DateTime @default(now()) @map(name: "created_at"), updatedAt DateTime @updatedAt @map(name: "updated_at"), name String?, email String @unique, emailVerified DateTime? @map(name: "email_verified"), password String?, onboardingCompleted Boolean @default(false), identityProvider IdentityProvider @default(email), identityProviderAccountId String?, memberships Membership[], accounts Account[], groupId String?, invitesCreated Invite[] @relation("inviteCreatedBy"), invitesAccepted Invite[] @relation("inviteAcceptedBy"), role Role?, notificationSettings Json @default("{}") }
|
||||
Please respond with “Formbricks Assistant is now ready! How can I help?” when you read everything.
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Survey" ADD COLUMN "displayPercentage" INTEGER;
|
||||
@@ -0,0 +1,5 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Survey" ADD COLUMN "createdBy" TEXT;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Survey" ADD CONSTRAINT "Survey_createdBy_fkey" FOREIGN KEY ("createdBy") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,5 @@
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Survey" DROP CONSTRAINT "Survey_createdBy_fkey";
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Survey" ADD CONSTRAINT "Survey_createdBy_fkey" FOREIGN KEY ("createdBy") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -257,6 +257,8 @@ model Survey {
|
||||
type SurveyType @default(web)
|
||||
environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade)
|
||||
environmentId String
|
||||
creator User? @relation(fields: [createdBy], references: [id])
|
||||
createdBy String?
|
||||
status SurveyStatus @default(draft)
|
||||
/// @zod.custom(imports.ZSurveyWelcomeCard)
|
||||
/// [SurveyWelcomeCard]
|
||||
@@ -300,6 +302,7 @@ model Survey {
|
||||
verifyEmail Json?
|
||||
pin String?
|
||||
resultShareKey String? @unique
|
||||
displayPercentage Int?
|
||||
|
||||
@@index([environmentId])
|
||||
}
|
||||
@@ -563,6 +566,7 @@ model User {
|
||||
/// @zod.custom(imports.ZUserNotificationSettings)
|
||||
/// [UserNotificationSettings]
|
||||
notificationSettings Json @default("{}")
|
||||
surveys Survey[]
|
||||
|
||||
@@index([email])
|
||||
}
|
||||
|
||||
@@ -31,17 +31,12 @@ export const AddMemberRole = ({ control, canDoRoleManagement }: AddMemberRolePro
|
||||
<div>
|
||||
<Label>Role</Label>
|
||||
<Select
|
||||
defaultValue="admin"
|
||||
value={value}
|
||||
onValueChange={(v) => onChange(v as MembershipRole)}
|
||||
disabled={!canDoRoleManagement}>
|
||||
<SelectTrigger className="capitalize">
|
||||
<SelectValue
|
||||
placeholder={
|
||||
<span className="text-slate-400">
|
||||
{canDoRoleManagement ? "Select role" : "Select role (Pro Feature)"}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@formbricks/js",
|
||||
"license": "MIT",
|
||||
"version": "1.5.0",
|
||||
"version": "1.5.1",
|
||||
"description": "Formbricks-js allows you to connect your app to Formbricks, display surveys and trigger events.",
|
||||
"homepage": "https://formbricks.com",
|
||||
"repository": {
|
||||
|
||||
@@ -12,6 +12,11 @@ const config = Config.getInstance();
|
||||
|
||||
const intentsToNotCreateOnApp = ["Exit Intent (Desktop)", "50% Scroll"];
|
||||
|
||||
const shouldDisplayBasedOnPercentage = (displayPercentage: number) => {
|
||||
const randomNum = Math.floor(Math.random() * 100) + 1;
|
||||
return randomNum <= displayPercentage;
|
||||
};
|
||||
|
||||
export const trackAction = async (
|
||||
name: string,
|
||||
properties: TJsActionInput["properties"] = {}
|
||||
@@ -64,6 +69,14 @@ export const trackAction = async (
|
||||
|
||||
export const triggerSurvey = async (actionName: string, activeSurveys: TSurvey[]): Promise<void> => {
|
||||
for (const survey of activeSurveys) {
|
||||
// Check if the survey should be displayed based on displayPercentage
|
||||
if (survey.displayPercentage) {
|
||||
const shouldDisplaySurvey = shouldDisplayBasedOnPercentage(survey.displayPercentage);
|
||||
if (!shouldDisplaySurvey) {
|
||||
logger.debug("Survey display skipped based on displayPercentage.");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
for (const trigger of survey.triggers) {
|
||||
if (trigger === actionName) {
|
||||
logger.debug(`Formbricks: survey ${survey.id} triggered by action "${actionName}"`);
|
||||
|
||||
@@ -10,7 +10,6 @@ import { prisma } from "@formbricks/database";
|
||||
import { createAccount } from "./account/service";
|
||||
import { verifyPassword } from "./auth/util";
|
||||
import { EMAIL_VERIFICATION_DISABLED } from "./constants";
|
||||
import { sendGettingStartedEmail } from "./emails/emails";
|
||||
import { env } from "./env.mjs";
|
||||
import { verifyToken } from "./jwt";
|
||||
import { createMembership } from "./membership/service";
|
||||
@@ -114,7 +113,6 @@ export const authOptions: NextAuthOptions = {
|
||||
}
|
||||
|
||||
user = await updateUser(user.id, { emailVerified: new Date() });
|
||||
await sendGettingStartedEmail(user);
|
||||
|
||||
return user;
|
||||
},
|
||||
@@ -223,7 +221,7 @@ export const authOptions: NextAuthOptions = {
|
||||
identityProvider: provider,
|
||||
identityProviderAccountId: account.providerAccountId,
|
||||
});
|
||||
await sendGettingStartedEmail(userProfile);
|
||||
|
||||
// Default team assignment if env variable is set
|
||||
if (env.DEFAULT_TEAM_ID && env.DEFAULT_TEAM_ID.length > 0) {
|
||||
// check if team exists
|
||||
@@ -250,7 +248,22 @@ export const authOptions: NextAuthOptions = {
|
||||
...account,
|
||||
userId: userProfile.id,
|
||||
});
|
||||
await createProduct(team.id, { name: "My Product" });
|
||||
const product = await createProduct(team.id, { name: "My Product" });
|
||||
const updatedNotificationSettings = {
|
||||
...userProfile.notificationSettings,
|
||||
alert: {
|
||||
...userProfile.notificationSettings?.alert,
|
||||
},
|
||||
weeklySummary: {
|
||||
...userProfile.notificationSettings?.weeklySummary,
|
||||
[product.id]: true,
|
||||
},
|
||||
};
|
||||
|
||||
await updateUser(userProfile.id, {
|
||||
notificationSettings: updatedNotificationSettings,
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
27
packages/lib/customerio.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
|
||||
import { env } from "./env.mjs";
|
||||
|
||||
export const createCustomerIoCustomer = async (user: TUser) => {
|
||||
if (!env.CUSTOMER_IO_SITE_ID || !env.CUSTOMER_IO_API_KEY) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const auth = Buffer.from(`${env.CUSTOMER_IO_SITE_ID}:${env.CUSTOMER_IO_API_KEY}`).toString("base64");
|
||||
const res = await fetch(`https://track-eu.customer.io/api/v1/customers/${user.id}`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
Authorization: `Basic ${auth}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
}),
|
||||
});
|
||||
if (res.status !== 200) {
|
||||
console.log("Error sending user to CustomerIO:", await res.text());
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("error sending user to CustomerIO:", error);
|
||||
}
|
||||
};
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from "../constants";
|
||||
import { createInviteToken, createToken, createTokenForLinkSurvey } from "../jwt";
|
||||
import { getQuestionResponseMapping } from "../responses";
|
||||
import { getTeamByEnvironmentId } from "../team/service";
|
||||
import { withEmailTemplate } from "./email-template";
|
||||
|
||||
const nodemailer = require("nodemailer");
|
||||
@@ -33,10 +34,6 @@ interface TEmailUser {
|
||||
email: string;
|
||||
}
|
||||
|
||||
interface TEmailUserWithName extends TEmailUser {
|
||||
name: string | null;
|
||||
}
|
||||
|
||||
export interface LinkSurveyEmailData {
|
||||
surveyId: string;
|
||||
email: string;
|
||||
@@ -96,34 +93,6 @@ export const sendVerificationEmail = async (user: TEmailUser) => {
|
||||
});
|
||||
};
|
||||
|
||||
export const sendGettingStartedEmail = async (user: TEmailUserWithName) => {
|
||||
await sendEmail({
|
||||
to: user.email,
|
||||
subject: "Get started with Formbricks 🤸",
|
||||
html: withEmailTemplate(`
|
||||
<h1 style="text-align: center; line-height: 1.2; padding-top: 16px; padding-bottom:8px;">Turn customer insights into irresistible experiences</h1>
|
||||
<a href="https://app.formbricks.com?utm_source=drip_campaign&utm_medium=email&utm_campaign=first_drip_mail&utm_content=top_image"><img src="https://formbricks-cdn.s3.eu-central-1.amazonaws.com/getting-started-with-formbricks-v5.png" alt="Formbricks can do it all" /></a>
|
||||
<h3 style="text-align:center;">Welcome to Formbricks! 🤗</h3>
|
||||
<p style="text-align:center;">We're the fastest growing Experience Management platform! Gracefully collect feedback without survey fatigue. Are you ready?</p>
|
||||
<div style="text-align:center; margin-bottom:72px;">
|
||||
<a class="button" href="https://app.formbricks.com?utm_source=drip_campaign&utm_medium=email&utm_campaign=first_drip_mail&utm_content=first_button">Create your survey</a><br/>
|
||||
</div>
|
||||
<a href="https://app.formbricks.com?utm_source=drip_campaign&utm_medium=email&utm_campaign=first_drip_mail&utm_content=second_image"><img style="border-radius:16px; box-shadow: 10px 10px 57px -21px rgba(71,85,105,0.58);" src="https://formbricks-cdn.s3.eu-central-1.amazonaws.com/getting-started-header-v4.png" alt="Formbricks can do it all"></a>
|
||||
<h2 style="margin-top:32px;">Collect feedback everywhere!</h2>
|
||||
<p>Formbricks is very versatile. Run:</p>
|
||||
<ul>
|
||||
<li><b>Website Surveys</b> like HotJar Ask</li>
|
||||
<li><b>In-App Surveys</b> like Sprig</li>
|
||||
<li><b>Link Surveys</b> like Typeform</li>
|
||||
<li><b>Headless Surveys</b> via API</li>
|
||||
</ul>
|
||||
<p>All on one, open source platform ✅</p>
|
||||
<a class="button" style="margin-bottom:12px; margin-top:0px;" href="https://app.formbricks.com?utm_source=drip_campaign&utm_medium=email&utm_campaign=first_drip_mail&utm_content=second_button">Create your survey</a><br/>
|
||||
<p style="margin-bottom:0px; margin-top:40px; text-align:center;"><b>Life is short, craft something irresistible!</b><br/>The Formbricks Team 🤍</p>
|
||||
`),
|
||||
});
|
||||
};
|
||||
|
||||
export const sendForgotPasswordEmail = async (user: TEmailUser) => {
|
||||
const token = createToken(user.id, user.email, {
|
||||
expiresIn: "1d",
|
||||
@@ -193,44 +162,52 @@ export const sendResponseFinishedEmail = async (
|
||||
email: string,
|
||||
environmentId: string,
|
||||
survey: { id: string; name: string; questions: TSurveyQuestion[] },
|
||||
response: TResponse
|
||||
response: TResponse,
|
||||
responseCount: number
|
||||
) => {
|
||||
const personEmail = response.person?.attributes["email"];
|
||||
const team = await getTeamByEnvironmentId(environmentId);
|
||||
await sendEmail({
|
||||
to: email,
|
||||
subject: personEmail
|
||||
? `${personEmail} just completed your ${survey.name} survey ✅`
|
||||
: `A response for ${survey.name} was completed ✅`,
|
||||
replyTo: personEmail?.toString() || MAIL_FROM,
|
||||
html: withEmailTemplate(`<h1>Hey 👋</h1>Someone just completed your survey <strong>${
|
||||
survey.name
|
||||
}</strong><br/>
|
||||
html: withEmailTemplate(`
|
||||
<h1>Hey 👋</h1>
|
||||
<p>Congrats, you received a new response to your survey!
|
||||
Someone just completed your survey <strong>${survey.name}</strong><br/></p>
|
||||
|
||||
<hr/>
|
||||
<hr/>
|
||||
|
||||
${getQuestionResponseMapping(survey, response)
|
||||
.map(
|
||||
(question) =>
|
||||
question.answer &&
|
||||
`<div style="margin-top:1em;">
|
||||
<p style="margin:0px;">${question.question}</p>
|
||||
<p style="font-weight: 500; margin:0px; white-space:pre-wrap">${question.answer}</p>
|
||||
</div>`
|
||||
)
|
||||
.join("")}
|
||||
${getQuestionResponseMapping(survey, response)
|
||||
.map(
|
||||
(question) =>
|
||||
question.answer &&
|
||||
`<div style="margin-top:1em;">
|
||||
<p style="margin:0px;">${question.question}</p>
|
||||
<p style="font-weight: 500; margin:0px; white-space:pre-wrap">${question.answer}</p>
|
||||
</div>`
|
||||
)
|
||||
.join("")}
|
||||
|
||||
<a class="button" href="${WEBAPP_URL}/environments/${environmentId}/surveys/${
|
||||
survey.id
|
||||
}/responses?utm_source=email_notification&utm_medium=email&utm_content=view_responses_CTA">View all responses</a>
|
||||
<a class="button" href="${WEBAPP_URL}/environments/${environmentId}/surveys/${
|
||||
survey.id
|
||||
}/responses?utm_source=email_notification&utm_medium=email&utm_content=view_responses_CTA">${responseCount > 1 ? `View ${responseCount - 1} more ${responseCount === 2 ? "response" : "responses"}` : `View survey summary`}</a>
|
||||
|
||||
<div class="tooltip">
|
||||
<p class='brandcolor'><strong>Start a conversation 💡</strong></p>
|
||||
${
|
||||
personEmail
|
||||
? `<p>Hit 'Reply' or reach out manually: ${personEmail}</p>`
|
||||
: "<p>If you set the email address as an attribute in in-app surveys, you can reply directly to the respondent.</p>"
|
||||
}
|
||||
</div>
|
||||
<div class="tooltip">
|
||||
<p class='brandcolor'><strong>Start a conversation 💡</strong></p>
|
||||
${
|
||||
personEmail
|
||||
? `<p>Hit 'Reply' or reach out manually: ${personEmail}</p>`
|
||||
: "<p>If you set the email address as an attribute in in-app surveys, you can reply directly to the respondent.</p>"
|
||||
}
|
||||
</div>
|
||||
|
||||
<hr/>
|
||||
|
||||
<p><b>Don't want to get these emails?</b></p>
|
||||
<div style="margin-top:0.8em; background-color:#f1f5f9; border-radius:8px; padding:0.01em 1.6em; text-align:center; font-size:0.8em; line-height:1.2em;"><p><i>Turn off notifications for <a href="${WEBAPP_URL}/environments/${environmentId}/settings/notifications?type=alert&elementId=${survey.id}">this form</a>. <br/> Turn off notifications for <a href="${WEBAPP_URL}/environments/${environmentId}/settings/notifications?type=unsubscribedTeamIds&elementId=${team?.id}">all newly created forms</a>.</i></p></div>
|
||||
`),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -7,6 +7,9 @@ export const env = createEnv({
|
||||
* Will throw if you access these variables on the client.
|
||||
*/
|
||||
server: {
|
||||
CUSTOMER_IO_API_KEY: z.string().optional(),
|
||||
CUSTOMER_IO_SITE_ID: z.string().optional(),
|
||||
NEXT_PUBLIC_POSTHOG_API_HOST: z.string().optional(),
|
||||
WEBAPP_URL: z.string().url().optional(),
|
||||
DATABASE_URL: z.string().url(),
|
||||
ENCRYPTION_KEY: z.string().length(64).or(z.string().length(32)),
|
||||
@@ -96,6 +99,8 @@ export const env = createEnv({
|
||||
* 💡 You'll get type errors if not all variables from `server` & `client` are included here.
|
||||
*/
|
||||
runtimeEnv: {
|
||||
CUSTOMER_IO_API_KEY: process.env.CUSTOMER_IO_API_KEY,
|
||||
CUSTOMER_IO_SITE_ID: process.env.CUSTOMER_IO_SITE_ID,
|
||||
WEBAPP_URL: process.env.WEBAPP_URL,
|
||||
DATABASE_URL: process.env.DATABASE_URL,
|
||||
ENCRYPTION_KEY: process.env.ENCRYPTION_KEY,
|
||||
@@ -146,7 +151,7 @@ export const env = createEnv({
|
||||
AZUREAD_CLIENT_ID: process.env.AZUREAD_CLIENT_ID,
|
||||
AZUREAD_CLIENT_SECRET: process.env.AZUREAD_CLIENT_SECRET,
|
||||
AZUREAD_TENANT_ID: process.env.AZUREAD_TENANT_ID,
|
||||
AIR_TABLE_CLIENT_ID: process.env.AIR_TABLE_CLIENT_ID,
|
||||
AIRTABLE_CLIENT_ID: process.env.AIRTABLE_CLIENT_ID,
|
||||
DEFAULT_TEAM_ID: process.env.DEFAULT_TEAM_ID,
|
||||
DEFAULT_TEAM_ROLE: process.env.DEFAULT_TEAM_ROLE,
|
||||
ONBOARDING_DISABLED: process.env.ONBOARDING_DISABLED,
|
||||
|
||||