Compare commits

..

56 Commits

Author SHA1 Message Date
Neil Chauhan
41e56c0651 feat: fix and add recall from previous questions with recall string 2023-11-23 18:49:59 +05:30
Neil Chauhan
f4f55481d6 feat: add the new @mention functionality to display recall values based on dummy options 2023-11-22 18:05:28 +05:30
Matti Nannt
3f8bf4c34c chore: simplify getPersonByUserId by removing legacy person support (#1649) 2023-11-20 21:07:40 +00:00
Matti Nannt
91ceffba01 fix: personByUserId not cached properly (#1644) 2023-11-20 20:05:58 +00:00
Shaik_Asif
8c38495812 fix: typo in template (#1648) 2023-11-20 19:58:05 +00:00
Matti Nannt
c8c98499ed chore: Simplify person service by removing complex getOrCreatePerson function (#1643) 2023-11-20 17:22:11 +00:00
Shubham Palriwala
af181eabdc feat: formbricks/api package as per js package 1.2.2 (#1640)
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2023-11-20 16:16:39 +00:00
Neil Chauhan
822c48ff52 fix: headline alignment issue (#1641)
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2023-11-20 15:02:46 +00:00
Neil Chauhan
70d211a038 fix: thank you card headline issue FOR-1489 (#1639) 2023-11-20 08:25:15 +00:00
Dhruwang Jariwala
a77ce55a1d fix: Error screen on survey Editor refresh (#1635) 2023-11-19 12:47:44 +00:00
Matti Nannt
a376eb9b51 fix: caching issue by simplifying person service (#1636) 2023-11-17 19:15:17 +00:00
Matti Nannt
f11c47d4ca chore: make getSyncSurveys cached by default (#1609) 2023-11-17 18:29:08 +00:00
Dhruwang Jariwala
4baea07471 fix: wording on look & feel page (#1632) 2023-11-17 09:46:43 +00:00
Johannes
ff87be717c fix: add canonical URL for lp (#1633) 2023-11-17 09:31:25 +00:00
Matti Nannt
e3e595af9a chore: upgrade packages, fix edge runtime issues (#1629) 2023-11-16 21:45:48 +00:00
Matti Nannt
3dae10d665 feat: load react email serverside (#1628) 2023-11-16 19:58:06 +00:00
Dhruwang Jariwala
6727ccf1cd fix: response delete in single response card (#1624) 2023-11-16 12:58:32 +00:00
Shubham Palriwala
9242ab3a7d feat: rate limit client API endpoints (#1566) 2023-11-16 12:57:19 +00:00
Matti Nannt
e9d8de3574 fix: zapier integration not able to pull past responses (#1627) 2023-11-16 12:07:30 +00:00
Dhruwang Jariwala
0a252e5827 fix: hidden field not working with prefilling on 1st question (#1602)
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2023-11-16 09:20:04 +00:00
Dhruwang Jariwala
632f6068c4 feat: test template (#1620) 2023-11-16 09:09:46 +00:00
Dhruwang Jariwala
4d280e04d1 fix: logic jumps (#1623) 2023-11-16 08:45:51 +00:00
Krishanu Saha
73bde4fda6 fix: #1357 button text color changed on nearwhite background color (#1365)
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2023-11-15 15:47:59 +00:00
Midka
9d4e21f8a7 feat(packages/surveys): ability to customize colors & other improvements (#916)
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
Co-authored-by: Neil Chauhan <neilchauhan2@gmail.com>
2023-11-15 15:29:27 +00:00
Dhruwang Jariwala
3eeea7d1b2 fix: survey editor issue (#1619) 2023-11-15 13:03:28 +00:00
Dhruwang Jariwala
32268a8ec3 fix: auto focus issue (#1618) 2023-11-15 11:35:01 +00:00
Matti Nannt
888dbbcfd2 feat: add toggle for in-app formbricks branding (#1616)
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: review-agent-prime[bot] <147289438+review-agent-prime[bot]@users.noreply.github.com>
2023-11-14 16:39:30 +00:00
Dhruwang Jariwala
73711b4631 refactor: Symlink replacement (#1556)
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2023-11-14 07:57:17 +00:00
Sushant
bd0b8ecd66 fix: #1610 (#1611) 2023-11-14 07:04:46 +00:00
Matti Nannt
fefc27aadd docs: userId typo in docs (#1612) 2023-11-14 06:51:50 +00:00
Shyam Raghuwanshi
8eb0cf3207 feat: Add color scheme for light and dark themes (#1368)
Co-authored-by: Johannes <johannes@formbricks.com>
2023-11-13 17:34:18 +00:00
Johannes
6a40ed705d fix: Update README.md (#1608) 2023-11-13 08:32:08 +00:00
Anshuman Pandey
53ef8771f3 feat: Make formbricks-js ready for public websites (#1470)
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
Co-authored-by: Johannes <johannes@formbricks.com>
2023-11-12 09:12:58 +00:00
Dhruwang Jariwala
ac8cf987d3 feat: Ttc on welcome card (#1461)
Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com>
2023-11-10 17:14:33 +00:00
Harish Gautam
11ede2e517 fix: Onboarding page added aria-lable and keyboard navigation (#1562)
Co-authored-by: Dhruwang Jariwala <67850763+Dhruwang@users.noreply.github.com>
2023-11-10 12:19:06 +00:00
dependabot[bot]
aa6d6df178 chore(deps): bump @sentry/nextjs from 7.76.0 to 7.77.0 (#1603)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-11-10 11:53:26 +00:00
Matti Nannt
c2675a9d49 fix: infinite loop in onboarding (#1604) 2023-11-10 11:01:43 +00:00
dominikmukrecki
84ce0c267c docs: Add Google OAuth Integration Guide (#1411)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2023-11-10 10:23:33 +00:00
Shubham Palriwala
a2df7abf85 fix: opt billing page forcefully out of static generation (#1600) 2023-11-09 10:13:25 +00:00
Shubham Palriwala
7acd2ccabb fix: github action that reports stripe usage (#1599) 2023-11-09 10:12:23 +00:00
Shubham Palriwala
a34606ab03 chore: remove unused method and restructure env var fetching for team roles (#1595)
Co-authored-by: Matti Nannt <mail@matthiasnannt.com>
2023-11-09 10:06:57 +00:00
Dhruwang Jariwala
dd0f0ead39 refactor: Airtable & GS endpoints to use responses return objects (#1592) 2023-11-09 10:01:49 +00:00
Shubham Palriwala
d6c9ce7c5b fix: handle invalid surveyId from api endpoint (#1589) 2023-11-09 09:59:42 +00:00
Matti Nannt
0b253e6ba5 chore: release formbricks-js 1.1.5 (#1601) 2023-11-09 09:53:34 +00:00
Florrdv
dd0091e52c fix: add types to exports for @formbricks/js (#1597) 2023-11-08 11:14:29 +00:00
Shubham Palriwala
c45248ada8 fix: redirect user if they visit onboarding page without auth (#1596) 2023-11-08 09:30:08 +00:00
Matti Nannt
ca21c9cea7 chore: Improve database performance by adding indexes (#1593) 2023-11-08 06:11:43 +00:00
Shubham Palriwala
10ab71b20f feat: independent troubleshooting guide page (#1591) 2023-11-07 21:30:14 +00:00
Shubham Palriwala
2acd18d8d5 feat: recommended hardware requirements to self host formbricks (#1590) 2023-11-07 21:26:40 +00:00
Shubham Palriwala
27b99d9761 fix: user existence checks in session object (#1587) 2023-11-07 21:26:20 +00:00
Shubham Palriwala
536e610895 fix: update setup checklists with latest js package version (#1588) 2023-11-07 21:24:03 +00:00
Jatin Sandilya
c40fedda90 chore: add revert to oss-friends (#745) 2023-11-06 16:14:01 +00:00
Shubham Palriwala
c74b3034fd fix: pass authOptions to getServerSession in authLayout (#1584) 2023-11-06 16:12:58 +00:00
Shashank
72fb1b3b30 fix: fixed URL example for "Create Survey" API endpoint is wrong #1555 (#1586) 2023-11-06 16:12:25 +00:00
Aditya Deshlahre
5859b51b8b fix(docs): default account info docs changes (#1583) 2023-11-06 14:25:42 +00:00
Matti Nannt
d51e17fe2e chore: Add docker packages to Github Packages on release (#1585) 2023-11-06 12:10:56 +00:00
268 changed files with 7219 additions and 5495 deletions

View File

@@ -122,7 +122,7 @@ GOOGLE_SHEETS_CLIENT_SECRET=
GOOGLE_SHEETS_REDIRECT_URL=
# Oauth credentials for Airtable integration
AIR_TABLE_CLIENT_ID=
AIRTABLE_CLIENT_ID=
# Enterprise License Key
ENTERPRISE_LICENSE_KEY=

View File

@@ -10,13 +10,13 @@ jobs:
cron-reportUsageToStripe:
env:
APP_URL: ${{ secrets.APP_URL }}
API_KEY: ${{ secrets.API_KEY }}
CRON_SECRET: ${{ secrets.CRON_SECRET }}
runs-on: ubuntu-latest
steps:
- name: cURL request
if: ${{ env.APP_URL && env.API_KEY }}
if: ${{ env.APP_URL && env.CRON_SECRET }}
run: |
curl ${{ env.APP_URL }}/api/cron/report-usage \
-X GET \
-H 'x-api-key: ${{ env.API_KEY }}' \
-X POST \
-H 'x-api-key: ${{ env.CRON_SECRET }}' \
--fail

View File

@@ -0,0 +1,93 @@
name: Docker
# This workflow uses actions that are not certified by GitHub.
# They are provided by a third-party and are governed by
# separate terms of service, privacy policy, and support
# documentation.
on:
push:
tags:
- "v*"
env:
# Use docker.io for Docker Hub if empty
REGISTRY: ghcr.io
# github.repository as <account>/<repo>
IMAGE_NAME: ${{ github.repository }}
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: Checkout repository
uses: actions/checkout@v3
# Install the cosign tool except on PR
# https://github.com/sigstore/cosign-installer
- name: Install cosign
if: github.event_name != 'pull_request'
uses: sigstore/cosign-installer@6e04d228eb30da1757ee4e1dd75a0ec73a653e06 #v3.1.1
with:
cosign-release: 'v2.1.1'
# Set up BuildKit Docker container builder to be able to build
# multi-platform images and export cache
# https://github.com/docker/setup-buildx-action
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0
# Login against a Docker registry except on PR
# https://github.com/docker/login-action
- name: Log into registry ${{ env.REGISTRY }}
if: github.event_name != 'pull_request'
uses: docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d # 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@96383f45573cb7f253c731d3b3ab81c87ef81934 # v5.0.0
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
# Build and push Docker image with Buildx (don't push on PR)
# https://github.com/docker/build-push-action
- name: Build and push Docker image
id: build-and-push
uses: docker/build-push-action@0565240e2d4ab88bba5387d719585280857ece09 # v5.0.0
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
# Sign the resulting Docker image digest except on PRs.
# This will only write to the public Rekor transparency log when the Docker
# repository is public to avoid leaking data. If you would like to publish
# transparency data even for private images, pass --force to cosign below.
# https://github.com/sigstore/cosign
- name: Sign the published Docker image
if: ${{ github.event_name != 'pull_request' }}
env:
# https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-an-intermediate-environment-variable
TAGS: ${{ steps.meta.outputs.tags }}
DIGEST: ${{ steps.build-and-push.outputs.digest }}
# This step uses the identity token to provision an ephemeral certificate
# against the sigstore community Fulcio instance.
run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST}

View File

@@ -60,11 +60,10 @@ Community managers will follow these Community Impact Guidelines in determining
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals.
**Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within the community.
## Attribution
This Code of Conduct is adapted from the Contributor Covenant, version 2.0, available at [https://www.contributor-covenant.org/version/2/0/code_of_conduct.html](https://www.contributor-covenant.org/version/2/0/code_of_conduct.html).

View File

@@ -6,7 +6,7 @@ Discover a myriad of ways to leave your mark on Formbricks — whether it's by s
## 🐛 Issue Hunters
Did you stumble upon a bug? Encountered a hiccup in deployment? Perhaps you have some user feedback to share? Your quickest route to help us out is by [raising an issue](https://github.com/formbricks/formbricks/issues/new/choose). We're on standby to respond swiftly.
Did you stumble upon a bug? Encountered a hiccup in deployment? Perhaps you have some user feedback to share? Your quickest route to help us out is by [raising an issue](https://github.com/formbricks/formbricks/issues/new/choose). We're on standby to respond swiftly.
## 💡 Feature Architects
@@ -20,13 +20,12 @@ Ready to dive into the code and make a real impact? Here's your path:
1. **Fork the Repository:** Fork our repository or use [Gitpod](https://formbricks.com/docs/contributing/gitpod)
2. **Tweak and Transform:** Work your coding magic and apply your changes.
1. **Tweak and Transform:** Work your coding magic and apply your changes.
3. **Pull Request Act:** If you're ready to go, craft a new pull request closely following our PR template 🙏
1. **Pull Request Act:** If you're ready to go, craft a new pull request closely following our PR template 🙏
Would you prefer a chat before you dive into a lot of work? Our [Discord server](https://formbricks.com/discord) is your harbor. Share your thoughts, and we'll meet you there with open arms. We're responsive and friendly, promise!
## 🚀 Aspiring Features
If you spot a feature that isn't part of our official plan but could propel Formbricks forward, don't hesitate. Raise it as an enhancement issue, and let us know you're ready to take the lead. We'll be quick to respond.

View File

@@ -1,7 +1,4 @@
<div id="top"></div>
[<img src="ph.png">](https://www.producthunt.com/posts/formbricks)
<p align="center">
<a href="https://formbricks.com">
<img width="120" alt="Open Source Experience Management Solution Qualtrics Alternative Logo" src="https://github.com/formbricks/formbricks/assets/72809645/0086704f-bee7-4d38-9cc8-fa42ee59e004">
@@ -18,7 +15,7 @@
<p align="center">
<a href="https://github.com/formbricks/formbricks/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-AGPL-purple" alt="License"></a> <a href="https://formbricks.com/discord"><img src="https://img.shields.io/discord/979077669410979880?label=Discord&logo=discord&logoColor=%23fff" alt="Join Formbricks Discord"></a> <a href="https://github.com/formbricks/formbricks/stargazers"><img src="https://img.shields.io/github/stars/formbricks/formbricks?logo=github" alt="Github Stars"></a>
<a href="https://news.ycombinator.com/item?id=32303986"><img src="https://img.shields.io/badge/Hacker%20News-122-%23FF6600" alt="Hacker News"></a>
<a href="https://www.producthunt.com/products/snoopforms"><img src="https://img.shields.io/badge/Product%20Hunt-%232%20Product%20of%20the%20Day-orange?logo=producthunt&logoColor=%23fff" alt="Product Hunt"></a>
<a href="[https://www.producthunt.com/products/formbricks](https://www.producthunt.com/posts/formbricks)"><img src="https://img.shields.io/badge/Product%20Hunt-455-orange?logo=producthunt&logoColor=%23fff" alt="Product Hunt"></a>
<a href="https://github.blog/2023-04-12-github-accelerator-our-first-cohort-and-whats-next/"><img src="https://img.shields.io/badge/2023-blue?logo=github&label=Github%20Accelerator" alt="Github Accelerator"></a>
<a href="https://github.com/formbricks/formbricks/issues?q=is:issue+is:open+label:%22%F0%9F%99%8B%F0%9F%8F%BB%E2%80%8D%E2%99%82%EF%B8%8Fhelp+wanted%22"><img src="https://img.shields.io/badge/Help%20Wanted-Contribute-blue"></a>
</p>

View File

@@ -1,38 +1,8 @@
import formbricks from "@formbricks/js";
import type { AppProps } from "next/app";
import Head from "next/head";
import { useRouter } from "next/router";
import { useEffect } from "react";
import "../styles/globals.css";
declare const window: any;
if (typeof window !== "undefined") {
if (process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID && process.env.NEXT_PUBLIC_FORMBRICKS_API_HOST) {
formbricks.init({
environmentId: process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID,
apiHost: process.env.NEXT_PUBLIC_FORMBRICKS_API_HOST,
debug: true,
});
window.formbricks = formbricks;
}
}
export default function App({ Component, pageProps }: AppProps) {
const router = useRouter();
useEffect(() => {
// Connect next.js router to Formbricks
if (process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID && process.env.NEXT_PUBLIC_FORMBRICKS_API_HOST) {
const handleRouteChange = formbricks?.registerRouteChange;
router.events.on("routeChangeComplete", handleRouteChange);
return () => {
router.events.off("routeChangeComplete", handleRouteChange);
};
}
}, []);
return (
<>
<Head>

View File

@@ -2,9 +2,13 @@ import formbricks from "@formbricks/js";
import Image from "next/image";
import { useEffect, useState } from "react";
import fbsetup from "../../public/fb-setup.png";
import { useRouter } from "next/router";
declare const window: any;
export default function AppPage({}) {
const [darkMode, setDarkMode] = useState(false);
const router = useRouter();
useEffect(() => {
if (darkMode) {
@@ -14,6 +18,30 @@ export default function AppPage({}) {
}
}, [darkMode]);
useEffect(() => {
if (process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID && process.env.NEXT_PUBLIC_FORMBRICKS_API_HOST) {
const isUserId = window.location.href.includes("userId=true");
const userId = isUserId ? "THIS-IS-A-VERY-LONG-USER-ID-FOR-TESTING" : undefined;
formbricks.init({
environmentId: process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID,
apiHost: process.env.NEXT_PUBLIC_FORMBRICKS_API_HOST,
userId,
debug: true,
});
window.formbricks = formbricks;
}
// Connect next.js router to Formbricks
if (process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID && process.env.NEXT_PUBLIC_FORMBRICKS_API_HOST) {
const handleRouteChange = formbricks?.registerRouteChange;
router.events.on("routeChangeComplete", handleRouteChange);
return () => {
router.events.off("routeChangeComplete", handleRouteChange);
};
}
});
return (
<div className="h-screen bg-white px-12 py-6 dark:bg-slate-800">
<div className="flex flex-col justify-between md:flex-row">
@@ -204,25 +232,37 @@ export default function AppPage({}) {
</div>
</div>
<div className="p-6">
<div>
<button
onClick={() => {
formbricks.setUserId("THIS-IS-A-VERY-LONG-USER-ID-FOR-TESTING");
}}
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-gray-700 dark:hover:bg-gray-600">
Set User ID
</button>
</div>
{router.query.userId === "true" ? (
<div>
<button
onClick={() => {
window.location.href = "/app";
}}
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-gray-700 dark:hover:bg-gray-600">
Deactivate User Identification
</button>
</div>
) : (
<div>
<button
onClick={() => {
window.location.href = "/app?userId=true";
}}
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-gray-700 dark:hover:bg-gray-600">
Activate User Identification
</button>
</div>
)}
<div>
<p className="text-xs text-slate-700 dark:text-gray-300">
This button sets an external{" "}
This button activates/deactivates{" "}
<a
href="https://formbricks.com/docs/attributes/identify-users"
target="_blank"
className="underline dark:text-blue-500">
user ID
user identification
</a>{" "}
to &apos;THIS-IS-A-VERY-LONG-USER-ID-FOR-TESTING&apos;
with the userId &apos;THIS-IS-A-VERY-LONG-USER-ID-FOR-TESTING&apos;
</p>
</div>
</div>

View File

@@ -1,152 +0,0 @@
import formbricks from "@formbricks/js";
import { useRouter } from "next/router";
import { FormEvent } from "react";
export default function SiginPage() {
const router = useRouter();
const submitAction = (e: FormEvent) => {
e.preventDefault();
if (process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID && process.env.NEXT_PUBLIC_FORMBRICKS_API_HOST) {
formbricks.setEmail("matti@example.com");
formbricks.setUserId("123456");
formbricks.setAttribute("Plan", "Premium");
}
router.push("/app");
};
return (
<div className="flex min-h-full flex-col justify-center py-12 sm:px-6 lg:px-8">
<div className="sm:mx-auto sm:w-full sm:max-w-md">
<h2 className="mt-6 text-center text-3xl font-bold tracking-tight text-gray-900">
Sign in to your account
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
Or{" "}
<a href="#" className="font-medium text-indigo-600 hover:text-indigo-500">
start your 14-day free trial
</a>
</p>
</div>
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div className="bg-white px-4 py-8 shadow sm:rounded-lg sm:px-10">
<form className="space-y-6" onSubmit={submitAction}>
<div>
<label htmlFor="email" className="block text-sm font-medium leading-6 text-gray-900">
Email address
</label>
<div className="mt-2">
<input
id="email"
name="email"
type="email"
autoComplete="email"
required
className="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
/>
</div>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium leading-6 text-gray-900">
Password
</label>
<div className="mt-2">
<input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
className="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
/>
</div>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center">
<input
id="remember-me"
name="remember-me"
type="checkbox"
className="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-600"
/>
<label htmlFor="remember-me" className="ml-2 block text-sm text-gray-900">
Remember me
</label>
</div>
<div className="text-sm">
<a href="#" className="font-medium text-indigo-600 hover:text-indigo-500">
Forgot your password?
</a>
</div>
</div>
<div>
<button
type="submit"
className="flex w-full justify-center rounded-md bg-indigo-500 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600">
Sign in
</button>
</div>
</form>
<div className="mt-6">
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-300" />
</div>
<div className="relative flex justify-center text-sm">
<span className="bg-white px-2 text-gray-500">Or continue with</span>
</div>
</div>
<div className="mt-6 grid grid-cols-3 gap-3">
<div>
<a
href="#"
className="inline-flex w-full justify-center rounded-md bg-white px-4 py-2 text-gray-500 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:outline-offset-0">
<span className="sr-only">Sign in with Facebook</span>
<svg className="h-5 w-5" aria-hidden="true" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M20 10c0-5.523-4.477-10-10-10S0 4.477 0 10c0 4.991 3.657 9.128 8.438 9.878v-6.987h-2.54V10h2.54V7.797c0-2.506 1.492-3.89 3.777-3.89 1.094 0 2.238.195 2.238.195v2.46h-1.26c-1.243 0-1.63.771-1.63 1.562V10h2.773l-.443 2.89h-2.33v6.988C16.343 19.128 20 14.991 20 10z"
clipRule="evenodd"
/>
</svg>
</a>
</div>
<div>
<a
href="#"
className="inline-flex w-full justify-center rounded-md bg-white px-4 py-2 text-gray-500 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:outline-offset-0">
<span className="sr-only">Sign in with Twitter</span>
<svg className="h-5 w-5" aria-hidden="true" fill="currentColor" viewBox="0 0 20 20">
<path d="M6.29 18.251c7.547 0 11.675-6.253 11.675-11.675 0-.178 0-.355-.012-.53A8.348 8.348 0 0020 3.92a8.19 8.19 0 01-2.357.646 4.118 4.118 0 001.804-2.27 8.224 8.224 0 01-2.605.996 4.107 4.107 0 00-6.993 3.743 11.65 11.65 0 01-8.457-4.287 4.106 4.106 0 001.27 5.477A4.073 4.073 0 01.8 7.713v.052a4.105 4.105 0 003.292 4.022 4.095 4.095 0 01-1.853.07 4.108 4.108 0 003.834 2.85A8.233 8.233 0 010 16.407a11.616 11.616 0 006.29 1.84" />
</svg>
</a>
</div>
<div>
<a
href="#"
className="inline-flex w-full justify-center rounded-md bg-white px-4 py-2 text-gray-500 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:outline-offset-0">
<span className="sr-only">Sign in with GitHub</span>
<svg className="h-5 w-5" aria-hidden="true" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M10 0C4.477 0 0 4.484 0 10.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0110 4.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.203 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.942.359.31.678.921.678 1.856 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0020 10.017C20 4.484 15.522 0 10 0z"
clipRule="evenodd"
/>
</svg>
</a>
</div>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,202 +0,0 @@
import formbricks from "@formbricks/js";
import Image from "next/image";
import { useEffect, useState } from "react";
import fbsetup from "../../public/fb-setup.png";
export default function AppPage({}) {
const [darkMode, setDarkMode] = useState(false);
useEffect(() => {
if (darkMode) {
document.body.classList.add("dark");
} else {
document.body.classList.remove("dark");
}
}, [darkMode]);
return (
<div className="h-full bg-white px-12 py-6 dark:bg-slate-800">
<div className="flex flex-col justify-between md:flex-row">
<div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">
Formbricks In-product Survey Demo App
</h1>
<p className="text-slate-700 dark:text-slate-300">
This app helps you test your in-app surveys. You can create and test user actions, create and
update user attributes, etc.
</p>
</div>
<button
className="mt-2 rounded-lg bg-slate-200 px-6 py-1 dark:bg-slate-700 dark:text-slate-100"
onClick={() => setDarkMode(!darkMode)}>
{darkMode ? "Toggle Light Mode" : "Toggle Dark Mode"}
</button>
</div>
<div className="my-4 grid grid-cols-1 gap-6 md:grid-cols-2">
<div>
<div className="rounded-lg border border-slate-300 bg-slate-100 p-6 dark:border-slate-600 dark:bg-slate-900">
<h3 className="text-lg font-semibold text-slate-900 dark:text-white">1. Setup .env</h3>
<p className="text-slate-700 dark:text-slate-300">
Copy the environment ID of your Formbricks app to the env variable in demo/.env
</p>
<Image src={fbsetup} alt="fb setup" className="mt-4 rounded" priority />
<div className="mt-4 flex-col items-start text-sm text-slate-700 dark:text-slate-300 sm:flex sm:items-center sm:text-base">
<p className="mb-1 sm:mb-0 sm:mr-2">You&apos;re connected with env:</p>
<div className="flex items-center">
<strong className="w-32 truncate sm:w-auto">
{process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID}
</strong>
<span className="relative ml-2 flex h-3 w-3">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-400 opacity-75"></span>
<span className="relative inline-flex h-3 w-3 rounded-full bg-green-500"></span>
</span>
</div>
</div>
</div>
<div className="mt-4 rounded-lg border border-slate-300 bg-slate-100 p-6 dark:border-slate-600 dark:bg-slate-900">
<h3 className="text-lg font-semibold text-slate-900 dark:text-white">2. Widget Logs</h3>
<p className="text-slate-700 dark:text-slate-300">
Look at the logs to understand how the widget works.{" "}
<strong className="dark:text-white">Open your browser console</strong> to see the logs.
</p>
{/* <div className="max-h-[40vh] overflow-y-auto py-4">
<LogsContainer />
</div> */}
</div>
</div>
<div className="md:grid md:grid-cols-3">
<div className="col-span-3 rounded-lg border border-slate-300 bg-slate-100 p-6 dark:border-gray-600 dark:bg-gray-800">
<h3 className="text-lg font-semibold dark:text-white">
Reset person / pull data from Formbricks app
</h3>
<p className="text-slate-700 dark:text-gray-300">
On formbricks.reset() a few things happen: <strong>New person is created</strong> and{" "}
<strong>surveys & no-code actions are pulled from Formbricks:</strong>.
</p>
<button
className="my-4 rounded-lg bg-slate-500 px-6 py-3 text-white hover:bg-slate-700 dark:bg-gray-700 dark:hover:bg-gray-600"
onClick={() => {
formbricks.reset();
}}>
Reset
</button>
<p className="text-xs text-slate-700 dark:text-gray-300">
If you made a change in Formbricks app and it does not seem to work, hit &apos;Reset&apos; and
try again.
</p>
</div>
<div className="p-6">
<div>
<button
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-gray-700 dark:hover:bg-gray-600"
onClick={() => {
console.log("Inner Text");
}}>
Inner Text
</button>
</div>
<div>
<p className="text-xs text-slate-700 dark:text-gray-300">Inner Text only</p>
</div>
</div>
<div className="p-6">
<div>
<button
id="css-id"
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-gray-700 dark:hover:bg-gray-600"
onClick={() => {
console.log("Inner Text + CSS ID");
}}>
Inner Text
</button>
</div>
<div>
<p className="text-xs text-slate-700 dark:text-gray-300">Inner Text + Css ID</p>
</div>
</div>
<div className="p-6">
<div>
<button
className="css-class mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-gray-700 dark:hover:bg-gray-600"
onClick={() => {
console.log("Inner Text + CSS Class");
}}>
Inner Text
</button>
</div>
<div>
<p className="text-xs text-slate-700 dark:text-gray-300">Inner Text + CSS Class</p>
</div>
</div>
<div className="p-6">
<div>
<button
id="css-id"
className="css-class mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-gray-700 dark:hover:bg-gray-600"
onClick={() => {
console.log("ID + Class");
}}>
ID and Class
</button>
</div>
<div>
<p className="text-xs text-slate-700 dark:text-gray-300">ID + Class</p>
</div>
</div>
<div className="p-6">
<div>
<button
id="css-id"
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-gray-700 dark:hover:bg-gray-600"
onClick={() => {
console.log("ID + Class");
}}>
ID only
</button>
</div>
<div>
<p className="text-xs text-slate-700 dark:text-gray-300">ID only</p>
</div>
</div>
<div className="p-6">
<div>
<button
className="css-class mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-gray-700 dark:hover:bg-gray-600"
onClick={() => {
console.log("Class only");
}}>
Class only
</button>
</div>
<div>
<p className="text-xs text-slate-700 dark:text-gray-300">Class only</p>
</div>
</div>
<div className="p-6">
<div>
<button
className="css-1 css-2 mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-gray-700 dark:hover:bg-gray-600"
onClick={() => {
console.log("Class + Class");
}}>
Class + Class
</button>
</div>
<div>
<p className="text-xs text-slate-700 dark:text-gray-300">Class + Class</p>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,3 +1,26 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Example on overriding packages/js colors */
.dark {
--fb-brand-color: red;
--fb-brand-text-color: white;
--fb-border-color: green;
--fb-border-color-highlight: var(--slate-500);
--fb-focus-color: red;
--fb-heading-color: yellow;
--fb-subheading-color: green;
--fb-info-text-color: orange;
--fb-signature-text-color: blue;
--fb-survey-background-color: black;
--fb-accent-background-color: rgb(13, 13, 12);
--fb-accent-background-color-selected: red;
--fb-placeholder-color: white;
--fb-shadow-color: yellow;
--fb-rating-fill: var(--yellow-300);
--fb-rating-hover: var(--yellow-500);
--fb-back-btn-border: currentColor;
--fb-submit-btn-border: transparent;
--fb-rating-selected: black;
}

View File

@@ -8,7 +8,10 @@ export const metadata = {
# Code Actions
Actions can also be set in the code base. You can fire an action using `formbricks.track()`
Actions can also be set in the codebase to trigger surveys. Please add the code action first in the Formbricks web interface to be able to configure your surveys to use this action.
After that you can fire an action using `formbricks.track()`
<Col>
<CodeGroup title="Track an action">

View File

@@ -146,7 +146,7 @@ This set of API can be used to
{
"id": "lkjaxb73ulydzeumhd51sx9g",
"type": "openText",
"headline": "What is the main benefit your receive from My Product?",
"headline": "What is the main benefit you receive from My Product?",
"required": true
},
{
@@ -412,7 +412,7 @@ This set of API can be used to
<CodeGroup title="Request" tag="POST" label="/api/v1/management/surveys">
```bash {{ title: 'cURL' }}
curl -X DELETE \
curl -X POST \
'https://app.formbricks.com/api/v1/management/surveys' \
--header \
'x-api-key: <your-api-key>'

View File

@@ -10,23 +10,29 @@ export const metadata = {
At Formbricks, we value user privacy. By default, Formbricks doesn't collect or store any personal information from your users. However, we understand that it can be helpful for you to know which user submitted the feedback and also functionality like recontacting users and controlling the waiting period between surveys requires identifying the users. That's why we provide a way for you to share existing user data from your app, so you can view it in our dashboard.
Once the Formbricks widget is loaded on your web app, our SDK exposes methods for identifying user attributes. Let's set it up!
If you would like to use the User Identification feature of Formbricks, target surveys to specific user segments and see more information about the user who responded to a survey, you can identify users by setting a User ID, email, and custom attributes. This guide will walk you through how to do that.
## Setting User ID
You can use the `setUserId` function to identify a user with any string. It's best to use the default identifier you use in your app (e.g. unique id from database) but you can also anonymize these as long as they are unique for every user. This function can be called multiple times with the same value safely and stores the identifier in local storage. We recommend you set the User ID whenever the user logs in to your website, as well as after the installation snippet (if the user is already logged in).
To enable the User identification feature you need to set the `userId` in the init() call of Formbricks. Only when the `userId` is set the person will be visible in the Formbricks dashboard. The `userId` can be any string and it's best to use the default identifier you use in your app (e.g. unique id from database or the email address if it's unique) but you can also anonymize these as long as they are unique for every user.
<Col>
<CodeGroup title="Setting User ID">
```javascript
formbricks.setUserId("USER_ID");
formbricks.init({
environmentId: "<environment-id>",
apiHost: "<api-host>",
userId: "<user_id>",
});
```
</CodeGroup>
</Col>
## Setting User Email
You can use the setEmail function to set the user's email:
The `userId` is the main identifier used in Formbricks and user identification is only enabled when it is set. In addition to the userId you can also set attributes that describes the user better. The email address can be set using the setEmail function:
<Col>
<CodeGroup title="Setting Email">
@@ -39,11 +45,12 @@ formbricks.setEmail("user@example.com");
### Setting Custom User Attributes
You can use the setAttribute function to set any custom attribute for the user (e.g. name, plan, etc.):
<Col>
<CodeGroup title="Setting Custom Attributes">
```javascript
formbricks.setAttribute("attribute_key", "attribute_value");
formbricks.setAttribute("Plan", "free");
```
</CodeGroup>
@@ -51,6 +58,7 @@ formbricks.setAttribute("attribute_key", "attribute_value");
### Logging Out Users
When a user logs out of your webpage, make sure to log them out of Formbricks as well. This will prevent new activity from being associated with an incorrect user. Use the logout function:
<Col>
<CodeGroup title="Logging out User">
@@ -59,4 +67,4 @@ formbricks.logout();
```
</CodeGroup>
</Col>
</Col>

View File

@@ -4,7 +4,8 @@ import DemoApp from "./demoapp.webp";
export const metadata = {
title: "Formbricks Demo App Guide: Play around with Formbricks",
description: "To test in-app surveys, trigger actions and set attributes, you can use the Demo App. This guide provides hands-on examples of sending both code and no-code actions",
description:
"To test in-app surveys, trigger actions and set attributes, you can use the Demo App. This guide provides hands-on examples of sending both code and no-code actions",
};
#### Contributing
@@ -13,13 +14,14 @@ export const metadata = {
To play around with the in-app [User Actions](/docs/actions/why), you can use the Demo App. It's a simple React app that you can run locally and use to trigger actions and set [Attributes](/docs/attributes/why).
<Image src={DemoApp} alt="Demo App Preview" quality="100" className="rounded-lg max-w-full sm:max-w-3xl" />
<Image src={DemoApp} alt="Demo App Preview" quality="100" className="max-w-full rounded-lg sm:max-w-3xl" />
## Functionality
### Code Action
This button sends a <a href="/docs/actions/code">Code Action</a> to the Formbricks API called 'Code Action'. You will find it in the Actions Tab.
<Col>
<CodeGroup title="Track Code action">
@@ -32,6 +34,7 @@ formbricks.track("Code Action");
### No Code Action
This button sends a <a href="/docs/actions/no-code">No Code Action</a> as long as you created it beforehand in the Formbricks App. For it to work, you need to add the No Code Action within Formbricks.
<Col>
<CodeGroup title="Track No-Code action">
@@ -44,6 +47,7 @@ This button sends a <a href="/docs/actions/no-code">No Code Action</a> as long a
### Set Plan to "Free"
This button sets the <a href="/docs/attributes/custom-attributes">attribute</a> 'Plan' to 'Free'. If the attribute does not exist, it creates it.
<Col>
<CodeGroup title="Set Plan to Free">
@@ -56,6 +60,7 @@ formbricks.setAttribute("Plan", "Free");
### Set Plan to "Paid"
This button sets the <a href="/docs/attributes/custom-attributes">attribute</a> 'Plan' to 'Paid'. If the attribute does not exist, it creates it.
<Col>
<CodeGroup title="Set Plan to Paid">
@@ -68,6 +73,7 @@ formbricks.setAttribute("Plan", "Paid");
### Set Email
This button sets the <a href="/docs/attributes/identify-users">user email</a> 'test@web.com'
<Col>
<CodeGroup title="Set Email">
@@ -79,13 +85,14 @@ formbricks.setEmail("test@web.com");
</Col>
### Set UserId
This button sets an external <a href="/docs/attributes/identify-users">user ID</a> to 'THIS-IS-A-VERY-LONG-USER-ID-FOR-TESTING'
This button sets an external <a href="/docs/attributes/identify-users">user ID</a> in the Formbricks init call to 'THIS-IS-A-VERY-LONG-USER-ID-FOR-TESTING'
<Col>
<CodeGroup title="Set User ID">
```tsx
formbricks.setUserId("THIS-IS-A-VERY-LONG-USER-ID-FOR-TESTING");
userId: "THIS-IS-A-VERY-LONG-USER-ID-FOR-TESTING";
```
</CodeGroup>
</Col>
</Col>

View File

@@ -90,6 +90,8 @@ To get the project running locally on your machine you need to have the followin
**You can now access the Formbricks app on [http://localhost:3000](http://localhost:3000)**. You will be automatically redirected to the login. To use your local installation of formbricks, create a new account.
<Note>A fresh setup does not have a default account. Please create a new account and proceed accordingly.</Note>
For viewing the confirmation email and other emails the system sends you, you can access mailhog at [http://localhost:8025](http://localhost:8025)
### Build

View File

@@ -45,7 +45,7 @@ All you need to do is copy a `<script>` tag to your HTML head, and thats abou
```html {{ title: 'index.html' }}
<!-- START Formbricks Surveys -->
<script type="text/javascript">
!function(){var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src="https://unpkg.com/@formbricks/js@^1.1.2/dist/index.umd.js";var e=document.getElementsByTagName("script")[0];e.parentNode.insertBefore(t,e),setTimeout(function(){window.formbricks.init({environmentId: "<your-environment-id>", apiHost: "<api-host>"})},500)}();
!function(){var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src="https://unpkg.com/@formbricks/js@^1.2.0/dist/index.umd.js";var e=document.getElementsByTagName("script")[0];e.parentNode.insertBefore(t,e),setTimeout(function(){window.formbricks.init({environmentId: "<your-environment-id>", apiHost: "<api-host>"})},500)}();
</script>
<!-- END Formbricks Surveys -->
```

View File

@@ -8,14 +8,12 @@ import I5 from "./5-options-survey-popup-in-app-for-feedback.webp";
import I6 from "./6-setup-in-app-survey-popup-feedback-box.webp";
import I7 from "./7-in-app-survey-popup-for-feedback.webp";
import I8 from "./8-pop-up-form-in-web-app-survey.webp";
import I9 from "./9-set-up-in-app-micro-survey-popup.webp";
import I10 from "./10-micro-survey-pop-up-in-app.webp";
import I11 from "./11-survey-logs-in-app-survey-popup.webp";
import ReactApp from "../framework-guides/react-in-app-survey-app-popup-form.webp";
export const metadata = {
title: "Formbricks Quickstart Guide: In-App Surveys Made Simple",
description: "Launch your first in-app survey effortlessly. Dive into our step-by-step guide to set up, integrate, and debug Formbricks in your web app in under 15 minutes.",
description:
"Launch your first in-app survey effortlessly. Dive into our step-by-step guide to set up, integrate, and debug Formbricks in your web app in under 15 minutes.",
};
#### Getting Started
@@ -32,7 +30,7 @@ While you can [self-host](/docs/self-hosting/deployment) Formbricks, the quickes
src={I1}
alt="Choose in app survey template"
quality="100"
className="max-w-full sm:max-w-3xl rounded-lg "
className="max-w-full rounded-lg sm:max-w-3xl "
/>
## Create your first survey
@@ -43,7 +41,7 @@ To be able to see a survey in your app, you need to create one. Well choose o
src={I2}
alt="Settings for popup survey inside web app"
quality="100"
className="rounded-lg max-w-full sm:max-w-3xl"
className="max-w-full rounded-lg sm:max-w-3xl"
/>
As you can see in the orange note here, we have not yet connected Formbricks Cloud with our app. We will do so in just a minute, lets first setup the survey correctly.
@@ -54,7 +52,7 @@ Select “Web App” in the How to ask settings:
src={I3}
alt="Survey settings for popup micro surve"
quality="100"
className="rounded-lg max-w-full sm:max-w-3xl"
className="max-w-full rounded-lg sm:max-w-3xl"
/>
Scroll down to Survey Trigger and choose “New Session”. This will cause this survey to appear when the Formbricks Widget tracks a new user session:
@@ -63,7 +61,7 @@ Scroll down to Survey Trigger and choose “New Session”. This will cause this
src={I4}
alt="In app survey trigger for feedback popup micro survey"
quality="100"
className="rounded-lg max-w-full sm:max-w-3xl"
className="max-w-full rounded-lg sm:max-w-3xl"
/>
In **Recontact Options** we choose the following settings, so that we can play around with the survey more easily. By default, each survey will be shown only once to each user to prevent survey fatigue:
@@ -72,7 +70,7 @@ In **Recontact Options** we choose the following settings, so that we can play a
src={I5}
alt="Options for survey popup in app micro survey"
quality="100"
className="rounded-lg max-w-full sm:max-w-3xl"
className="max-w-full rounded-lg sm:max-w-3xl"
/>
Now hit **Publish** and youll be forwarded to the Summary Page. This is where youll find the responses to this survey. On the Summary Page click through to the Setup Checklist:
@@ -81,7 +79,7 @@ Now hit **Publish** and youll be forwarded to the Summary Page. This is where
src={I6}
alt="pop up survey settings for inapp web survey"
quality="100"
className="rounded-lg max-w-full sm:max-w-3xl"
className="max-w-full rounded-lg sm:max-w-3xl"
/>
## Set up the Formbricks Widget in your app
@@ -92,7 +90,7 @@ On the Setup Checklist you have two elements. At the top you find the Widget Sta
src={I7}
alt="feedback popup in app survey"
quality="100"
className="rounded-lg max-w-full sm:max-w-3xl"
className="max-w-full rounded-lg sm:max-w-3xl"
/>
In the manual below, this code snippet contains all the information you need:
@@ -104,9 +102,11 @@ In the manual below, this code snippet contains all the information you need:
src={I8}
alt="settings for in app survey popping up"
quality="100"
className="rounded-lg max-w-full sm:max-w-3xl"
className="max-w-full rounded-lg sm:max-w-3xl"
/>
If you like to use the user identification feature, please follow the [user identification guide](/docs/attributes/identify-users).
## Load Formbricks widget in your app
In a local instance of your app, you'll embed the Formbricks Widget. Dependent on your frontend tech, the setup differs a bit:
@@ -124,60 +124,5 @@ Now, restart your app in your terminal to make sure the widget is loaded. Once i
src={ReactApp}
alt="In app survey in React app for micro surveys"
quality="100"
className="rounded-lg max-w-full sm:max-w-3xl"
className="max-w-full rounded-lg sm:max-w-3xl"
/>
## Most frequent errors and how to debug them
In case you dont see your survey right away, here's what you can do. Go through these in orderly to find the error fast:
### Formbricks Cloud and your app are not connected properly.
Go back to [app.formbricks.com](http://app.formbricks.com) and go to the Setup Checklist in the Settings. If the status is still indicated as “Not connected” your app hasn't yet pinged the Formbricks Cloud:
<Image
src={I9}
alt="setup checklist ui of survey popup for in app surveys"
quality="100"
className="rounded-lg max-w-full sm:max-w-3xl"
/>
**How to fix it:**
1. Check if your app loads the Formbricks widget correctly. Make sure you have `debug` mode enabled in your integration and you should see the Formbricks debug logs in your browser console while being in your app (right click in the browser, `Inspect`, switch to the console tab). If you dont see them, double check your integration or if you are unable to solve this issue, please [join our Discord](https://formbricks.com/discord) and well help you out.
---
### Survey not loaded
If your app is connected with Formbricks Cloud, the survey might have not been loaded properly. Check the debug logs and search for the list of surveys loaded. It should look like so:
<Image
src={I11}
alt="survey logs for in app survey pop up micro"
quality="100"
className="rounded-lg max-w-full sm:max-w-3xl"
/>
**How to fix it:**
The widget only loads surveys which are **public** and **in progress**. Go to Formbricks Cloud and to the Survey Summary page. Check if your survey is live:
<Image
src={I10}
alt="ui of survey popup for in app micro surveys"
quality="100"
className="rounded-lg max-w-full sm:max-w-3xl"
/>
---
### Survey not triggered
If the survey is loaded by the widget it might not have been triggered properly.
**How to fix:**
1. Open your local app in an incognito tab or window. The New Session event is only fired if a user was inactive for 60 minutes or was logged out of Formbricks via formrbicks.logout().
2. Check the debug logs for “Event New Session” tracked”. If you see it in the logs and the survey still did not get displayed, [please let us know.](mailto:support@formbricks.com)
That should fix it! If not, please [join our Discord](https://formbricks.com/discord)! Were more than happy to help you get started 😊

View File

@@ -0,0 +1,96 @@
import Image from "next/image";
import I1 from "./1-set-up-in-app-micro-survey-popup.webp";
import I2 from "./2-micro-survey-pop-up-in-app.webp";
import I3 from "./3-survey-logs-in-app-survey-popup.webp";
export const metadata = {
title: "Formbricks Troubleshooting Guide",
description:
"Troubleshoot your Formbricks integration and get your in-app surveys up and running in no time. Most frequent errors and how to debug them.",
};
#### Getting Started
# Troubleshooting Guide
In case you dont see your survey right away, here's what you can do. Go through these to find the error fast:
## Formbricks Cloud and your app are not connected properly.
Go back to [app.formbricks.com](http://app.formbricks.com) or your self-hosted instance's URL and go to the Setup Checklist in the Settings. If the status is still indicated as “Not connected” your app hasn't yet pinged the Formbricks Cloud:
<Image
src={I1}
alt="setup checklist ui of survey popup for in app surveys"
quality="100"
className="max-w-full rounded-lg sm:max-w-3xl"
/>
**How to fix it:**
1. Check if your app loads the Formbricks widget correctly.
2. Make sure you have `debug` mode enabled in your integration and you should see the Formbricks debug logs in your browser console while being in your app (right click in the browser, `Inspect`, switch to the console tab). If you dont see them, double check your integration.
---
## Survey not loaded
If your app is connected with Formbricks Cloud, the survey might have not been loaded properly. Check the debug logs and search for the list of surveys loaded. It should look like so:
<Image
src={I3}
alt="survey logs for in app survey pop up micro"
quality="100"
className="max-w-full rounded-lg sm:max-w-3xl"
/>
**How to fix it:**
The widget only loads surveys which are **public** and **in progress**. Go to Formbricks Cloud and to the Survey Summary page. Check if your survey is live:
<Image
src={I2}
alt="ui of survey popup for in app micro surveys"
quality="100"
className="max-w-full rounded-lg sm:max-w-3xl"
/>
---
## Survey not triggered
If the survey is loaded by the widget it might not have been triggered properly.
**How to fix:**
1. Open your local app in an incognito tab or window. The New Session event is only fired if a user was inactive for 60 minutes or was logged out of Formbricks via formrbicks.logout().
2. Check the debug logs for “Event New Session” tracked”. If you see it in the logs and the survey still did not get displayed, [please let us know.](mailto:support@formbricks.com)
---
## Survey not displayed in HTML page
If the survey is loaded by the widget in the HTML page, try the below steps:
**How to fix:**
1. Make sure you have added the [script](/docs/getting-started/framework-guides#html) in the head of the HTML page.
2. Verify that you have set the \<environment-id\> and \<host\> as per your Formbricks instance.
3. Verify that you have the latest version of the JS Package.
4. Check the debug logs to see if you still see any errors.
---
## Cannot read undefined of .init()
If you see this error in the console, it means that the Formbricks JS package is not loaded properly.
**How to fix:**
1. Update to the latest version of the JS Package.
2. Verify this wherever you call initialise the Formbricks instance in your code.
3. It should now start working.
---
If you are still facing issues, please [Open an Issue on GitHub](https://github.com/formbricks/formbricks/issues) or [join our Discord](https://formbricks.com/discord)! Were more than happy to help you get started 😊

View File

@@ -160,8 +160,8 @@ Enabling the Airtable Integration in a self-hosted environment requires creating
### By now, your environment variables should include the below ones:
- `AIR_TABLE_CLIENT_ID`
- `AIR_TABLE_REDIRECT_URL`
- `AIRTABLE_CLIENT_ID`
- `AIRTABLE_REDIRECT_URL`
Voila! You have successfully enabled the Airtable integration in your self-hosted Formbricks instance. Now you can follow the steps mentioned in the [Formbricks Cloud](#formbricks-cloud) section to link an Airtable with Formbricks.

View File

@@ -16,4 +16,6 @@ At Formbricks, we understand that different users have different needs, and we s
Please note that regardless of the method you choose, Formbricks is designed to be easy-to-use and flexible. So choose the method that best fits your comfort level and requirements, and start leveraging the **power of Formbricks** today!
<Note>Running Formbricks requires at least **1 vCPU**, **2 GBs of RAM**, **8 GBs of SSD**</Note>
Looking for something not mentioned here? [Join our Discord!](https://formbricks.com/discord) and we'd be glad to assist you!

View File

@@ -0,0 +1,66 @@
export const metadata = {
title: "External auth providers",
description:
"Set up and integrate multiple external authentication providers with Formbricks. Our step-by-step guide covers Google OAuth and more, ensuring a seamless login experience for your users.",
};
## Google OAuth Authentication
Integrating Google OAuth with your Formbricks instance allows users to log in using their Google credentials, ensuring a secure and streamlined user experience. This guide will walk you through the process of setting up Google OAuth for your Formbricks instance.
### Requirements
- A Google Cloud Platform (GCP) account.
- A Formbricks instance running and accessible.
### Steps
1. **Create a GCP Project**:
- Navigate to the [GCP Console](https://console.cloud.google.com/).
- From the projects list, select a project or create a new one.
2. **Setting up OAuth 2.0**:
- If the **APIs & services** page isn't already open, open the console left side menu and select **APIs & services**.
- On the left, click **Credentials**.
- Click **Create Credentials**, then select **OAuth client ID**.
3. **Configure OAuth Consent Screen**:
- If this is your first time creating a client ID, configure your consent screen by clicking **Consent Screen**.
- Fill in the necessary details and under **Authorized domains**, add the domain where your Formbricks instance is hosted.
4. **Create OAuth 2.0 Client IDs**:
- Select the application type **Web application** for your project and enter any additional information required.
- Ensure to specify authorized JavaScript origins and authorized redirect URIs.
```
Authorized JavaScript origins: {WEBAPP_URL}
Authorized redirect URIs: {WEBAPP_URL}/api/auth/callback/google
```
5. **Update Environment Variables in Docker**:
- To integrate the Google OAuth, you have two options: either update the environment variables in the docker-compose file or directly add them to the running container.
- In your Docker setup directory, open the `.env` file, and add or update the following lines with the `Client ID` and `Client Secret` obtained from Google Cloud Platform:
- Alternatively, you can add the environment variables directly to the running container using the following commands (replace `container_id` with your actual Docker container ID):
```
docker exec -it container_id /bin/bash
export GOOGLE_AUTH_ENABLED=1
export GOOGLE_CLIENT_ID=your-client-id-here
export GOOGLE_CLIENT_SECRET=your-client-secret-here
exit
```
```
GOOGLE_AUTH_ENABLED=1
GOOGLE_CLIENT_ID=your-client-id-here
GOOGLE_CLIENT_SECRET=your-client-secret-here
```
6. **Restart Your Formbricks Instance**:
- **Note:** Restarting your Docker containers may cause a brief period of downtime. Plan accordingly.
- Once the environment variables have been updated, it's crucial to restart your Docker containers to apply the changes. This ensures that your Formbricks instance can utilize the new Google OAuth configuration for user authentication. Here's how you can do it:
- Navigate to your Docker setup directory where your `docker-compose.yml` file is located.
- Run the following command to bring down your current Docker containers and then bring them back up with the updated environment configuration:

View File

@@ -193,6 +193,7 @@ export const navigation: Array<NavGroup> = [
links: [
{ title: "Quickstart: In app", href: "/docs/getting-started/quickstart-in-app-survey" },
{ title: "Framework Guides", href: "/docs/getting-started/framework-guides" },
{ title: "Troubleshooting", href: "/docs/getting-started/troubleshooting" },
],
},
{
@@ -248,6 +249,7 @@ export const navigation: Array<NavGroup> = [
{ title: "Production", href: "/docs/self-hosting/production" },
{ title: "Docker", href: "/docs/self-hosting/docker" },
{ title: "Migration Guide", href: "/docs/self-hosting/migration-guide" },
{ title: "External auth providers", href: "/docs/self-hosting/external-auth-providers" },
],
},
{

View File

@@ -4,13 +4,13 @@ import { PlusIcon, TrashIcon } from "@heroicons/react/24/solid";
import { useState } from "react";
const DummyUI: React.FC = () => {
const eventClasses = [
const actionClasses = [
{ id: "1", name: "View Dashboard" },
{ id: "2", name: "Upgrade to Pro" },
{ id: "3", name: "Cancel Plan" },
];
const [triggers, setTriggers] = useState<string[]>([eventClasses[0].id]);
const [triggers, setTriggers] = useState<string[]>([actionClasses[0].id]);
const setTriggerEvent = (index: number, eventClassId: string) => {
setTriggers((prevTriggers) =>
@@ -19,7 +19,7 @@ const DummyUI: React.FC = () => {
};
const addTriggerEvent = () => {
setTriggers((prevTriggers) => [...prevTriggers, eventClasses[0].id]);
setTriggers((prevTriggers) => [...prevTriggers, actionClasses[0].id]);
};
const removeTriggerEvent = (index: number) => {
@@ -41,12 +41,12 @@ const DummyUI: React.FC = () => {
<SelectValue />
</SelectTrigger>
<SelectContent>
{eventClasses.map((eventClass) => (
{actionClasses.map((actionClass) => (
<SelectItem
key={eventClass.id}
key={actionClass.id}
className="xs:text-base px-0.5 py-1 text-xs text-slate-800 dark:bg-slate-700 dark:text-slate-300 dark:ring-slate-700"
value={eventClass.id}>
{eventClass.name}
value={actionClass.id}>
{actionClass.name}
</SelectItem>
))}
</SelectContent>

View File

@@ -134,7 +134,7 @@ export const templates: TTemplate[] = [
{
id: createId(),
type: TSurveyQuestionType.OpenText,
headline: "What is the main benefit your receive from Formbricks?",
headline: "What is the main benefit you receive from Formbricks?",
inputType: "text",
longAnswer: true,
required: true,

View File

@@ -60,7 +60,7 @@ if (typeof window !== "undefined") {
</>
) : activeTab === "html" ? (
<CodeBlock>{`<script type="text/javascript">
!function(){var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src="https://unpkg.com/@formbricks/js@^1.1.2/dist/index.umd.js";var e=document.getElementsByTagName("script")[0];e.parentNode.insertBefore(t,e),setTimeout(function(){window.formbricks.init("claDadXk29dak92dK9","https://app.formbricks.com")},500)}();
!function(){var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src="https://unpkg.com/@formbricks/js@^1.2.0/dist/index.umd.js";var e=document.getElementsByTagName("script")[0];e.parentNode.insertBefore(t,e),setTimeout(function(){window.formbricks.init("claDadXk29dak92dK9","https://app.formbricks.com")},500)}();
</script>`}</CodeBlock>
) : null}
</div>

View File

@@ -30,6 +30,7 @@ export default function MetaInformation({
<meta name="image" content={`https://${BASE_URL}/favicon.ico`} />
<meta property="og:image" content={`https://${BASE_URL}/social-image.png`} />
<link rel="icon" type="image/x-icon" href={`https://${BASE_URL}/favicon.ico`} />
<link rel="canonical" href="https://formbricks.com/" />
<meta name="msapplication-TileColor" content="#00C4B8" />
<meta name="msapplication-TileImage" content={`https://${BASE_URL}/favicon.ico`} />
<meta property="og:image:alt" content="Open Source Experience Management, Privacy-first" />

View File

@@ -127,6 +127,11 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
"Makes frontend development cycle 10x faster with API Client, Mock Server, Intercept & Modify HTTP Requests and Session Replays.",
href: "https://requestly.io",
},
{
name: "Revert",
description: "The open-source unified API to build B2B integrations remarkably fast",
href: "https://revert.dev",
},
{
name: "Rivet",
description: "Open-source solution to deploy, scale, and operate your multiplayer game.",

View File

@@ -1,3 +1,5 @@
export const dynamic = "force-dynamic";
import ConfirmationPage from "./components/ConfirmationPage";
export default function BillingConfirmation({ searchParams }) {

View File

@@ -1,6 +1,6 @@
"use client";
import { env } from "@/env.mjs";
import { env } from "@formbricks/lib/env.mjs";
import { formbricksEnabled } from "@/app/lib/formbricks";
import formbricks from "@formbricks/js";
import { useEffect } from "react";
@@ -15,8 +15,8 @@ export default function FormbricksClient({ session }) {
formbricks.init({
environmentId: env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID || "",
apiHost: env.NEXT_PUBLIC_FORMBRICKS_API_HOST || "",
userId: session.user.id,
});
formbricks.setUserId(session.user.id);
formbricks.setEmail(session.user.email);
}
}, [session]);

View File

@@ -1,5 +1,5 @@
"use client";
import { env } from "@/env.mjs";
import { env } from "@formbricks/lib/env.mjs";
import type { Session } from "next-auth";
import { usePostHog } from "posthog-js/react";
import { useEffect } from "react";

View File

@@ -3,7 +3,7 @@
import { Button } from "@formbricks/ui/Button";
import { CursorArrowRaysIcon } from "@heroicons/react/24/solid";
import { useState } from "react";
import AddNoCodeActionModal from "./AddNoCodeActionModal";
import AddNoCodeActionModal from "./AddActionModal";
import ActionDetailModal from "./ActionDetailModal";
import { TActionClass } from "@formbricks/types/actionClasses";
import { useMembershipRole } from "@formbricks/lib/membership/hooks/useMembershipRole";

View File

@@ -0,0 +1,270 @@
"use client";
import { createActionClassAction } from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/actions";
import { CssSelector } from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/components/CssSelector";
import { InnerHtmlSelector } from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/components/InnerHtmlSelector";
import { PageUrlSelector } from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/components/PageUrlSelector";
import { TActionClass, TActionClassInput, TActionClassNoCodeConfig } from "@formbricks/types/actionClasses";
import { Alert, AlertDescription, AlertTitle } from "@formbricks/ui/Alert";
import { Button } from "@formbricks/ui/Button";
import { Input } from "@formbricks/ui/Input";
import { Label } from "@formbricks/ui/Label";
import { Modal } from "@formbricks/ui/Modal";
import { TabBar } from "@formbricks/ui/TabBar";
import { CursorArrowRaysIcon } from "@heroicons/react/24/solid";
import { Terminal } from "lucide-react";
import { useState } from "react";
import { useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { testURLmatch } from "../lib/testURLmatch";
interface AddNoCodeActionModalProps {
environmentId: string;
open: boolean;
setOpen: (v: boolean) => void;
setActionClassArray?;
isViewer: boolean;
}
function isValidCssSelector(selector?: string) {
if (!selector || selector.length === 0) {
return false;
}
const element = document.createElement("div");
try {
element.querySelector(selector);
} catch (err) {
return false;
}
return true;
}
export default function AddNoCodeActionModal({
environmentId,
open,
setOpen,
setActionClassArray,
isViewer,
}: AddNoCodeActionModalProps) {
const { register, control, handleSubmit, watch, reset } = useForm();
const [isPageUrl, setIsPageUrl] = useState(false);
const [isCssSelector, setIsCssSelector] = useState(false);
const [isInnerHtml, setIsInnerText] = useState(false);
const [isCreatingAction, setIsCreatingAction] = useState(false);
const [testUrl, setTestUrl] = useState("");
const [isMatch, setIsMatch] = useState("");
const [type, setType] = useState("noCode");
const filterNoCodeConfig = (noCodeConfig: TActionClassNoCodeConfig): TActionClassNoCodeConfig => {
const { pageUrl, innerHtml, cssSelector } = noCodeConfig;
const filteredNoCodeConfig: TActionClassNoCodeConfig = {};
if (isPageUrl && pageUrl?.rule && pageUrl?.value) {
filteredNoCodeConfig.pageUrl = { rule: pageUrl.rule, value: pageUrl.value };
}
if (isInnerHtml && innerHtml?.value) {
filteredNoCodeConfig.innerHtml = { value: innerHtml.value };
}
if (isCssSelector && cssSelector?.value) {
filteredNoCodeConfig.cssSelector = { value: cssSelector.value };
}
return filteredNoCodeConfig;
};
const handleMatchClick = () => {
const match = testURLmatch(
testUrl,
watch("noCodeConfig.[pageUrl].value"),
watch("noCodeConfig.[pageUrl].rule")
);
setIsMatch(match);
if (match === "yes") toast.success("Your survey would be shown on this URL.");
if (match === "no") toast.error("Your survey would not be shown.");
};
const submitEventClass = async (data: Partial<TActionClassInput>): Promise<void> => {
const { noCodeConfig } = data;
try {
if (isViewer) {
throw new Error("You are not authorised to perform this action.");
}
setIsCreatingAction(true);
if (data.name === "") throw new Error("Please give your action a name");
if (type === "noCode") {
if (!isPageUrl && !isCssSelector && !isInnerHtml)
throw new Error("Please select at least one selector");
if (isCssSelector && !isValidCssSelector(noCodeConfig?.cssSelector?.value)) {
throw new Error("Please enter a valid CSS Selector");
}
if (isPageUrl && noCodeConfig?.pageUrl?.rule === undefined) {
throw new Error("Please select a rule for page URL");
}
}
const updatedAction: TActionClassInput = {
name: data.name,
description: data.description,
environmentId,
type,
} as TActionClassInput;
if (type === "noCode") {
const filteredNoCodeConfig = filterNoCodeConfig(noCodeConfig as TActionClassNoCodeConfig);
updatedAction.noCodeConfig = filteredNoCodeConfig;
}
const newActionClass: TActionClass = await createActionClassAction(updatedAction);
if (setActionClassArray) {
setActionClassArray((prevActionClassArray: TActionClass[]) => [
...prevActionClassArray,
newActionClass,
]);
}
reset();
resetAllStates(false);
toast.success("Action added successfully.");
} catch (e) {
toast.error(e.message);
} finally {
setIsCreatingAction(false);
}
};
const resetAllStates = (open: boolean) => {
setIsCssSelector(false);
setIsPageUrl(false);
setIsInnerText(false);
setTestUrl("");
setIsMatch("");
reset();
setOpen(open);
};
return (
<Modal open={open} setOpen={() => resetAllStates(false)} noPadding closeOnOutsideClick={false}>
<div className="flex h-full flex-col rounded-lg">
<div className="rounded-t-lg bg-slate-100">
<div className="flex w-full items-center justify-between p-6">
<div className="flex items-center space-x-2">
<div className="mr-1.5 h-6 w-6 text-slate-500">
<CursorArrowRaysIcon />
</div>
<div>
<div className="text-xl font-medium text-slate-700">Track New User Action</div>
<div className="text-sm text-slate-500">
Track a user action to display surveys or create user segment.
</div>
</div>
</div>
</div>
</div>
<TabBar
tabs={[
{ id: "noCode", label: "No Code" },
{ id: "code", label: "Code" },
]}
activeId={type}
setActiveId={setType}
/>
{type === "noCode" ? (
<form onSubmit={handleSubmit(submitEventClass)}>
<div className="flex justify-between rounded-lg p-6">
<div className="w-full space-y-4">
<div className="grid w-full grid-cols-2 gap-x-4">
<div className="col-span-1">
<Label>What did your user do?</Label>
<Input placeholder="E.g. Clicked Download" {...register("name", { required: true })} />
</div>
<div className="col-span-1">
<Label>Description</Label>
<Input placeholder="User clicked Download Button " {...register("description")} />
</div>
</div>
<div>
<Label>Select By</Label>
</div>
<CssSelector
isCssSelector={isCssSelector}
setIsCssSelector={setIsCssSelector}
register={register}
/>
<PageUrlSelector
isPageUrl={isPageUrl}
setIsPageUrl={setIsPageUrl}
register={register}
control={control}
testUrl={testUrl}
setTestUrl={setTestUrl}
isMatch={isMatch}
setIsMatch={setIsMatch}
handleMatchClick={handleMatchClick}
/>
<InnerHtmlSelector
isInnerHtml={isInnerHtml}
setIsInnerHtml={setIsInnerText}
register={register}
/>
</div>
</div>
<div className="flex justify-end border-t border-slate-200 p-6">
<div className="flex space-x-2">
<Button type="button" variant="minimal" onClick={() => resetAllStates(false)}>
Cancel
</Button>
<Button variant="darkCTA" type="submit" loading={isCreatingAction}>
Track Action
</Button>
</div>
</div>
</form>
) : (
<form onSubmit={handleSubmit(submitEventClass)}>
<div className="flex justify-between rounded-lg p-6">
<div className="w-full space-y-4">
<div className="grid w-full grid-cols-2 gap-x-4">
<div className="col-span-1">
<Label>Identifier</Label>
<Input placeholder="E.g. clicked-download" {...register("name", { required: true })} />
</div>
<div className="col-span-1">
<Label>Description</Label>
<Input placeholder="User clicked Download Button " {...register("description")} />
</div>
</div>
<hr />
<Alert>
<Terminal className="h-4 w-4" />
<AlertTitle>How do Code Actions work?</AlertTitle>
<AlertDescription>
You can track code action anywhere in your app using{" "}
<span className="rounded bg-gray-100 px-2 py-1 text-xs">
formbricks.track(&quot;{watch("name")}&quot;)
</span>{" "}
in your code. Read more in our{" "}
<a href="https://formbricks.com/docs/actions/code" target="_blank" className="underline">
docs
</a>
.
</AlertDescription>
</Alert>
</div>
</div>
<div className="flex justify-end border-t border-slate-200 p-6">
<div className="flex space-x-2">
<Button type="button" variant="minimal" onClick={() => resetAllStates(false)}>
Cancel
</Button>
<Button variant="darkCTA" type="submit" loading={isCreatingAction}>
Track Action
</Button>
</div>
</div>
</form>
)}
</div>
</Modal>
);
}

View File

@@ -1,207 +0,0 @@
"use client";
import { createActionClassAction } from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/actions";
import { CssSelector } from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/components/CssSelector";
import { InnerHtmlSelector } from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/components/InnerHtmlSelector";
import { PageUrlSelector } from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/components/PageUrlSelector";
import { TActionClass, TActionClassInput, TActionClassNoCodeConfig } from "@formbricks/types/actionClasses";
import { Button } from "@formbricks/ui/Button";
import { Input } from "@formbricks/ui/Input";
import { Label } from "@formbricks/ui/Label";
import { Modal } from "@formbricks/ui/Modal";
import { CursorArrowRaysIcon } from "@heroicons/react/24/solid";
import { useState } from "react";
import { useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { testURLmatch } from "../lib/testURLmatch";
interface AddNoCodeActionModalProps {
environmentId: string;
open: boolean;
setOpen: (v: boolean) => void;
setActionClassArray?;
isViewer: boolean;
}
function isValidCssSelector(selector?: string) {
if (!selector || selector.length === 0) {
return false;
}
const element = document.createElement("div");
try {
element.querySelector(selector);
} catch (err) {
return false;
}
return true;
}
export default function AddNoCodeActionModal({
environmentId,
open,
setOpen,
setActionClassArray,
isViewer,
}: AddNoCodeActionModalProps) {
const { register, control, handleSubmit, watch, reset } = useForm();
const [isPageUrl, setIsPageUrl] = useState(false);
const [isCssSelector, setIsCssSelector] = useState(false);
const [isInnerHtml, setIsInnerText] = useState(false);
const [isCreatingAction, setIsCreatingAction] = useState(false);
const [testUrl, setTestUrl] = useState("");
const [isMatch, setIsMatch] = useState("");
const filterNoCodeConfig = (noCodeConfig: TActionClassNoCodeConfig): TActionClassNoCodeConfig => {
const { pageUrl, innerHtml, cssSelector } = noCodeConfig;
const filteredNoCodeConfig: TActionClassNoCodeConfig = {};
if (isPageUrl && pageUrl?.rule && pageUrl?.value) {
filteredNoCodeConfig.pageUrl = { rule: pageUrl.rule, value: pageUrl.value };
}
if (isInnerHtml && innerHtml?.value) {
filteredNoCodeConfig.innerHtml = { value: innerHtml.value };
}
if (isCssSelector && cssSelector?.value) {
filteredNoCodeConfig.cssSelector = { value: cssSelector.value };
}
return filteredNoCodeConfig;
};
const handleMatchClick = () => {
const match = testURLmatch(
testUrl,
watch("noCodeConfig.[pageUrl].value"),
watch("noCodeConfig.[pageUrl].rule")
);
setIsMatch(match);
if (match === "yes") toast.success("Your survey would be shown on this URL.");
if (match === "no") toast.error("Your survey would not be shown.");
};
const submitEventClass = async (data: Partial<TActionClassInput>): Promise<void> => {
const { noCodeConfig } = data;
try {
if (isViewer) {
throw new Error("You are not authorised to perform this action.");
}
setIsCreatingAction(true);
if (data.name === "") throw new Error("Please give your action a name");
if (!isPageUrl && !isCssSelector && !isInnerHtml)
throw new Error("Please select at least one selector");
if (isCssSelector && !isValidCssSelector(noCodeConfig?.cssSelector?.value)) {
throw new Error("Please enter a valid CSS Selector");
}
if (isPageUrl && noCodeConfig?.pageUrl?.rule === undefined) {
throw new Error("Please select a rule for page URL");
}
const filteredNoCodeConfig = filterNoCodeConfig(noCodeConfig as TActionClassNoCodeConfig);
const updatedData: TActionClassInput = {
...data,
environmentId,
noCodeConfig: filteredNoCodeConfig,
type: "noCode",
} as TActionClassInput;
const newActionClass: TActionClass = await createActionClassAction(updatedData);
if (setActionClassArray) {
setActionClassArray((prevActionClassArray: TActionClass[]) => [
...prevActionClassArray,
newActionClass,
]);
}
reset();
resetAllStates(false);
toast.success("Action added successfully.");
} catch (e) {
toast.error(e.message);
} finally {
setIsCreatingAction(false);
}
};
const resetAllStates = (open: boolean) => {
setIsCssSelector(false);
setIsPageUrl(false);
setIsInnerText(false);
setTestUrl("");
setIsMatch("");
reset();
setOpen(open);
};
return (
<Modal open={open} setOpen={() => resetAllStates(false)} noPadding closeOnOutsideClick={false}>
<div className="flex h-full flex-col rounded-lg">
<div className="rounded-t-lg bg-slate-100">
<div className="flex w-full items-center justify-between p-6">
<div className="flex items-center space-x-2">
<div className="mr-1.5 h-6 w-6 text-slate-500">
<CursorArrowRaysIcon />
</div>
<div>
<div className="text-xl font-medium text-slate-700">Track New User Action</div>
<div className="text-sm text-slate-500">
Track a user action to display surveys or create user segment.
</div>
</div>
</div>
</div>
</div>
<form onSubmit={handleSubmit(submitEventClass)}>
<div className="flex justify-between rounded-lg p-6">
<div className="w-full space-y-4">
<div className="grid w-full grid-cols-2 gap-x-4">
<div className="col-span-1">
<Label>What did your user do?</Label>
<Input placeholder="E.g. Clicked Download" {...register("name", { required: true })} />
</div>
<div className="col-span-1">
<Label>Description</Label>
<Input placeholder="User clicked Download Button " {...register("description")} />
</div>
</div>
<div>
<Label>Select By</Label>
</div>
<CssSelector
isCssSelector={isCssSelector}
setIsCssSelector={setIsCssSelector}
register={register}
/>
<PageUrlSelector
isPageUrl={isPageUrl}
setIsPageUrl={setIsPageUrl}
register={register}
control={control}
testUrl={testUrl}
setTestUrl={setTestUrl}
isMatch={isMatch}
setIsMatch={setIsMatch}
handleMatchClick={handleMatchClick}
/>
<InnerHtmlSelector
isInnerHtml={isInnerHtml}
setIsInnerHtml={setIsInnerText}
register={register}
/>
</div>
</div>
<div className="flex justify-end border-t border-slate-200 p-6">
<div className="flex space-x-2">
<Button type="button" variant="minimal" onClick={() => resetAllStates(false)}>
Cancel
</Button>
<Button variant="darkCTA" type="submit" loading={isCreatingAction}>
Track Action
</Button>
</div>
</div>
</form>
</div>
</Modal>
);
}

View File

@@ -7,7 +7,7 @@ import type { AttributeClass } from "@prisma/client";
import { useForm } from "react-hook-form";
import { ArchiveBoxArrowDownIcon, ArchiveBoxXMarkIcon } from "@heroicons/react/24/solid";
import { useRouter } from "next/navigation";
import { updatetAttributeClass } from "@formbricks/lib/attributeClass/service";
import { updateAttributeClass } from "@formbricks/lib/attributeClass/service";
import { useState } from "react";
interface AttributeSettingsTabProps {
@@ -25,7 +25,7 @@ export default function AttributeSettingsTab({ attributeClass, setOpen }: Attrib
const onSubmit = async (data) => {
setisAttributeBeingSubmitted(true);
setOpen(false);
await updatetAttributeClass(attributeClass.id, data);
await updateAttributeClass(attributeClass.id, data);
router.refresh();
setisAttributeBeingSubmitted(false);
};
@@ -33,7 +33,7 @@ export default function AttributeSettingsTab({ attributeClass, setOpen }: Attrib
const handleArchiveToggle = async () => {
setisAttributeBeingSubmitted(true);
const data = { archived: !attributeClass.archived };
await updatetAttributeClass(attributeClass.id, data);
await updateAttributeClass(attributeClass.id, data);
setisAttributeBeingSubmitted(false);
};

View File

@@ -91,7 +91,7 @@ export async function copyToOtherEnvironmentAction(
include: {
triggers: {
include: {
eventClass: true,
actionClass: true,
},
},
attributeFilters: {
@@ -109,9 +109,9 @@ export async function copyToOtherEnvironmentAction(
let targetEnvironmentTriggers: string[] = [];
// map the local triggers to the target environment
for (const trigger of existingSurvey.triggers) {
const targetEnvironmentTrigger = await prisma.eventClass.findFirst({
const targetEnvironmentTrigger = await prisma.actionClass.findFirst({
where: {
name: trigger.eventClass.name,
name: trigger.actionClass.name,
environment: {
id: targetEnvironmentId,
},
@@ -119,18 +119,18 @@ export async function copyToOtherEnvironmentAction(
});
if (!targetEnvironmentTrigger) {
// if the trigger does not exist in the target environment, create it
const newTrigger = await prisma.eventClass.create({
const newTrigger = await prisma.actionClass.create({
data: {
name: trigger.eventClass.name,
name: trigger.actionClass.name,
environment: {
connect: {
id: targetEnvironmentId,
},
},
description: trigger.eventClass.description,
type: trigger.eventClass.type,
noCodeConfig: trigger.eventClass.noCodeConfig
? JSON.parse(JSON.stringify(trigger.eventClass.noCodeConfig))
description: trigger.actionClass.description,
type: trigger.actionClass.type,
noCodeConfig: trigger.actionClass.noCodeConfig
? JSON.parse(JSON.stringify(trigger.actionClass.noCodeConfig))
: undefined,
},
});
@@ -183,8 +183,8 @@ export async function copyToOtherEnvironmentAction(
questions: JSON.parse(JSON.stringify(existingSurvey.questions)),
thankYouCard: JSON.parse(JSON.stringify(existingSurvey.thankYouCard)),
triggers: {
create: targetEnvironmentTriggers.map((eventClassId) => ({
eventClassId: eventClassId,
create: targetEnvironmentTriggers.map((actionClassId) => ({
actionClassId: actionClassId,
})),
},
attributeFilters: {

View File

@@ -1,7 +1,5 @@
import { getLatestActionByEnvironmentId } from "@formbricks/lib/action/service";
import { getEnvironment, updateEnvironment } from "@formbricks/lib/environment/service";
import { timeSince } from "@formbricks/lib/time";
import { ArrowDownIcon, CheckIcon, ExclamationTriangleIcon } from "@heroicons/react/24/solid";
import { getEnvironment } from "@formbricks/lib/environment/service";
import { ArrowDownIcon, CheckIcon } from "@heroicons/react/24/solid";
import clsx from "clsx";
import Link from "next/link";
@@ -11,13 +9,10 @@ interface WidgetStatusIndicatorProps {
}
export default async function WidgetStatusIndicator({ environmentId, type }: WidgetStatusIndicatorProps) {
const [environment, latestAction] = await Promise.all([
getEnvironment(environmentId),
getLatestActionByEnvironmentId(environmentId),
]);
const environment = await getEnvironment(environmentId);
if (!environment?.widgetSetupCompleted && latestAction) {
await updateEnvironment(environment.id, { widgetSetupCompleted: true });
if (!environment) {
throw new Error("Environment not found");
}
const stati = {
@@ -27,26 +22,18 @@ export default async function WidgetStatusIndicator({ environmentId, type }: Wid
title: "Connect Formbricks to your app.",
subtitle: "You have not yet connected Formbricks to your app. Follow setup guide.",
},
running: { icon: CheckIcon, color: "green", title: "Receiving data.", subtitle: "Last action received:" },
issue: {
icon: ExclamationTriangleIcon,
color: "amber",
title: "There might be an issue.",
subtitle: "Last action received:",
running: {
icon: CheckIcon,
color: "green",
title: "Receiving data.",
subtitle: "You have successfully connected Formbricks to your app.",
},
};
let status: "notImplemented" | "running" | "issue";
if (latestAction) {
const currentTime = new Date();
const timeDifference = currentTime.getTime() - new Date(latestAction.createdAt).getTime();
if (timeDifference <= 24 * 60 * 60 * 1000) {
status = "running";
} else {
status = "issue";
}
if (environment.widgetSetupCompleted) {
status = "running";
} else {
status = "notImplemented";
}
@@ -59,23 +46,18 @@ export default async function WidgetStatusIndicator({ environmentId, type }: Wid
className={clsx(
"flex flex-col items-center justify-center space-y-2 rounded-lg py-6 text-center",
status === "notImplemented" && "bg-slate-100",
status === "running" && "bg-green-100",
status === "issue" && "bg-amber-100"
status === "running" && "bg-green-100"
)}>
<div
className={clsx(
"h-12 w-12 rounded-full bg-white p-2",
status === "notImplemented" && "text-slate-700",
status === "running" && "text-green-700",
status === "issue" && "text-amber-700"
status === "running" && "text-green-700"
)}>
<currentStatus.icon />
</div>
<p className="text-md font-bold text-slate-800 md:text-xl">{currentStatus.title}</p>
<p className="text-sm text-slate-700">
{currentStatus.subtitle}{" "}
{latestAction && <span>{timeSince(latestAction.createdAt.toISOString())}</span>}
</p>
<p className="text-sm text-slate-700">{currentStatus.subtitle}</p>
</div>
);
}
@@ -84,16 +66,12 @@ export default async function WidgetStatusIndicator({ environmentId, type }: Wid
<Link href={`/environments/${environment.id}/settings/setup`}>
<div className="group my-4 flex justify-center">
<div className=" flex rounded-full bg-slate-100 px-2 py-1">
<p className="mr-2 text-sm text-slate-400 group-hover:underline">
{currentStatus.subtitle}{" "}
{latestAction && <span>{timeSince(latestAction.createdAt.toISOString())}</span>}
</p>
<p className="mr-2 text-sm text-slate-400 group-hover:underline">{currentStatus.subtitle}</p>
<div
className={clsx(
"h-5 w-5 rounded-full p-0.5",
status === "notImplemented" && "bg-slate-100 text-slate-700",
status === "running" && "bg-green-100 text-green-700",
status === "issue" && "bg-amber-100 text-amber-700"
status === "running" && "bg-green-100 text-green-700"
)}>
<currentStatus.icon />
</div>

View File

@@ -6,8 +6,8 @@ export const fetchTables = async (environmentId: string, baseId: string) => {
headers: { environmentId: environmentId },
cache: "no-store",
});
return res.json() as Promise<TIntegrationAirtableTables>;
const resJson = await res.json();
return resJson.data as Promise<TIntegrationAirtableTables>;
};
export const authorize = async (environmentId: string, apiHost: string): Promise<string> => {
@@ -21,6 +21,6 @@ export const authorize = async (environmentId: string, apiHost: string): Promise
throw new Error("Could not create response");
}
const resJSON = await res.json();
const authUrl = resJSON.authUrl;
const authUrl = resJSON.data.authUrl;
return authUrl;
};

View File

@@ -1,6 +1,6 @@
import AirtableWrapper from "@/app/(app)/environments/[environmentId]/integrations/airtable/components/AirtableWrapper";
import { getAirtableTables } from "@formbricks/lib/airtable/service";
import { AIR_TABLE_CLIENT_ID, WEBAPP_URL } from "@formbricks/lib/constants";
import { AIRTABLE_CLIENT_ID, WEBAPP_URL } from "@formbricks/lib/constants";
import { getEnvironment } from "@formbricks/lib/environment/service";
import { getIntegrations } from "@formbricks/lib/integration/service";
import { getSurveys } from "@formbricks/lib/survey/service";
@@ -9,7 +9,7 @@ import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
import GoBackButton from "@formbricks/ui/GoBackButton";
export default async function Airtable({ params }) {
const enabled = !!AIR_TABLE_CLIENT_ID;
const enabled = !!AIRTABLE_CLIENT_ID;
const [surveys, integrations, environment] = await Promise.all([
getSurveys(params.environmentId),
getIntegrations(params.environmentId),

View File

@@ -9,6 +9,6 @@ export const authorize = async (environmentId: string, apiHost: string): Promise
throw new Error("Could not create response");
}
const resJSON = await res.json();
const authUrl = resJSON.authUrl;
const authUrl = resJSON.data.authUrl;
return authUrl;
};

View File

@@ -11,7 +11,7 @@ import { AuthorizationError } from "@formbricks/types/errors";
export default async function EnvironmentLayout({ children, params }) {
const session = await getServerSession(authOptions);
if (!session) {
if (!session || !session.user) {
return redirect(`/auth/login`);
}
const hasAccess = await hasUserEnvironmentAccess(session.user.id, params.environmentId);

View File

@@ -5,14 +5,13 @@ import { REVALIDATION_INTERVAL } from "@formbricks/lib/constants";
import { capitalizeFirstLetter } from "@formbricks/lib/strings";
import { getPerson } from "@formbricks/lib/person/service";
import { getResponsesByPersonId } from "@formbricks/lib/response/service";
import { getSessionCount } from "@formbricks/lib/session/service";
export default async function AttributesSection({ personId }: { personId: string }) {
const person = await getPerson(personId);
if (!person) {
throw new Error("No such person found");
}
const numberOfSessions = await getSessionCount(personId);
const responses = await getResponsesByPersonId(personId);
const numberOfResponses = responses?.length || 0;
@@ -35,6 +34,8 @@ export default async function AttributesSection({ personId }: { personId: string
<dd className="ph-no-capture mt-1 text-sm text-slate-900">
{person.attributes.userId ? (
<span>{person.attributes.userId}</span>
) : person.userId ? (
<span>{person.userId}</span>
) : (
<span className="text-slate-300">Not provided</span>
)}
@@ -56,8 +57,8 @@ export default async function AttributesSection({ personId }: { personId: string
<hr />
<div>
<dt className="text-sm font-medium text-slate-500">Sessions</dt>
<dd className="mt-1 text-sm text-slate-900">{numberOfSessions}</dd>
{/* <dt className="text-sm font-medium text-slate-500">Sessions</dt> */}
{/* <dd className="mt-1 text-sm text-slate-900">{numberOfSessions}</dd> */}
</div>
<div>
<dt className="text-sm font-medium text-slate-500">Responses</dt>

View File

@@ -12,7 +12,8 @@ export default function Loading() {
{
id: "demoId1",
createdAt: new Date(),
sessionId: "",
// sessionId: "",
personId: "",
properties: {},
actionClass: {
id: "demoId1",
@@ -28,7 +29,8 @@ export default function Loading() {
{
id: "demoId2",
createdAt: new Date(),
sessionId: "",
// sessionId: "",
personId: "",
properties: {},
actionClass: {
id: "demoId2",

View File

@@ -80,7 +80,7 @@ export default async function PeoplePage({
</div>
<div className="col-span-2 my-auto hidden whitespace-nowrap text-center text-sm text-slate-500 sm:block">
<div className="ph-no-capture text-slate-900">
{truncateMiddle(getAttributeValue(person, "userId"), 24)}
{truncateMiddle(getAttributeValue(person, "userId"), 24) || person.userId}
</div>
</div>
<div className="col-span-2 my-auto hidden whitespace-nowrap text-center text-sm text-slate-500 sm:block">

View File

@@ -0,0 +1,88 @@
"use client";
import { TProduct, TProductUpdateInput } from "@formbricks/types/product";
import { Alert, AlertDescription } from "@formbricks/ui/Alert";
import { Label } from "@formbricks/ui/Label";
import { Switch } from "@formbricks/ui/Switch";
import Link from "next/link";
import { useState } from "react";
import toast from "react-hot-toast";
import { updateProductAction } from "../actions";
interface EditFormbricksBrandingProps {
type: "linkSurvey" | "inAppSurvey";
product: TProduct;
canRemoveBranding: boolean;
environmentId: string;
}
export function EditFormbricksBranding({
type,
product,
canRemoveBranding,
environmentId,
}: EditFormbricksBrandingProps) {
const [isBrandingEnabled, setIsBrandingEnabled] = useState(
type === "linkSurvey" ? product.linkSurveyBranding : product.inAppSurveyBranding
);
const [updatingBranding, setUpdatingBranding] = useState(false);
const getTextFromType = (type) => {
if (type === "linkSurvey") return "Link Surveys";
if (type === "inAppSurvey") return "In App Surveys";
};
const toggleBranding = async () => {
try {
setUpdatingBranding(true);
const newBrandingState = !isBrandingEnabled;
setIsBrandingEnabled(newBrandingState);
let inputProduct: Partial<TProductUpdateInput> = {
[type === "linkSurvey" ? "linkSurveyBranding" : "inAppSurveyBranding"]: newBrandingState,
};
await updateProductAction(product.id, inputProduct);
toast.success(
newBrandingState ? "Formbricks branding will be shown." : "Formbricks branding will now be hidden."
);
} catch (error) {
toast.error(`Error: ${error.message}`);
} finally {
setUpdatingBranding(false);
}
};
return (
<div className="w-full items-center">
{!canRemoveBranding && (
<div className="mb-4">
<Alert>
<AlertDescription>
To remove the Formbricks branding from the&nbsp;
<span className="font-semibold">{getTextFromType(type)}</span>, please&nbsp;
{type === "linkSurvey" ? (
<span className="underline">
<Link href={`/environments/${environmentId}/settings/billing`}>upgrade your plan.</Link>
</span>
) : (
<span className="underline">
<Link href={`/environments/${environmentId}/settings/billing`}>add your creditcard.</Link>
</span>
)}
</AlertDescription>
</Alert>
</div>
)}
<div className="mb-6 flex items-center space-x-2">
<Switch
id={`branding-${type}`}
checked={isBrandingEnabled}
onCheckedChange={toggleBranding}
disabled={!canRemoveBranding || updatingBranding}
/>
<Label htmlFor={`branding-${type}`}>
Show Formbricks Branding in {type === "linkSurvey" ? "Link" : "In-App"} Surveys
</Label>
</div>
</div>
);
}

View File

@@ -1,67 +0,0 @@
"use client";
import { Alert, AlertDescription } from "@formbricks/ui/Alert";
import { updateProductAction } from "../actions";
import { TProduct, TProductUpdateInput } from "@formbricks/types/product";
import { Label } from "@formbricks/ui/Label";
import { Switch } from "@formbricks/ui/Switch";
import { useState } from "react";
import toast from "react-hot-toast";
import Link from "next/link";
interface EditSignatureProps {
product: TProduct;
canRemoveSignature: boolean;
environmentId: string;
}
export function EditFormbricksSignature({ product, canRemoveSignature, environmentId }: EditSignatureProps) {
const [formbricksSignature, setFormbricksSignature] = useState(product.formbricksSignature);
const [updatingSignature, setUpdatingSignature] = useState(false);
const toggleSignature = async () => {
try {
setUpdatingSignature(true);
const newSignatureState = !formbricksSignature;
setFormbricksSignature(newSignatureState);
let inputProduct: Partial<TProductUpdateInput> = {
formbricksSignature: newSignatureState,
};
await updateProductAction(product.id, inputProduct);
toast.success(
newSignatureState ? "Formbricks signature will be shown." : "Formbricks signature will now be hidden."
);
} catch (error) {
toast.error(`Error: ${error.message}`);
} finally {
setUpdatingSignature(false);
}
};
return (
<div className="w-full items-center">
{!canRemoveSignature && (
<div className="mb-4">
<Alert>
<AlertDescription>
To remove the Formbricks branding from the link surveys, please{" "}
<span className="underline">
<Link href={`/environments/${environmentId}/settings/billing`}>upgrade</Link>
</span>{" "}
your plan.
</AlertDescription>
</Alert>
</div>
)}
<div className="flex items-center space-x-2">
<Switch
id="signature"
checked={formbricksSignature}
onCheckedChange={toggleSignature}
disabled={!canRemoveSignature || updatingSignature}
/>
<Label htmlFor="signature">Show &apos;Powered by Formbricks&apos; Signature in Link Surveys</Label>
</div>
</div>
);
}

View File

@@ -1,20 +1,19 @@
export const revalidate = REVALIDATION_INTERVAL;
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
import { REVALIDATION_INTERVAL } from "@formbricks/lib/constants";
import SettingsCard from "../components/SettingsCard";
import SettingsTitle from "../components/SettingsTitle";
import { EditFormbricksSignature } from "./components/EditSignature";
import { EditBrandColor } from "./components/EditBrandColor";
import { EditPlacement } from "./components/EditPlacement";
import { EditHighlightBorder } from "./components/EditHighlightBorder";
import { DEFAULT_BRAND_COLOR } from "@formbricks/lib/constants";
import { authOptions } from "@formbricks/lib/authOptions";
import { getServerSession } from "next-auth";
import { getTeamByEnvironmentId } from "@formbricks/lib/team/service";
import { DEFAULT_BRAND_COLOR, REVALIDATION_INTERVAL } from "@formbricks/lib/constants";
import { getMembershipByUserIdTeamId } from "@formbricks/lib/membership/service";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
import { getTeamByEnvironmentId } from "@formbricks/lib/team/service";
import { ErrorComponent } from "@formbricks/ui/ErrorComponent";
import { getServerSession } from "next-auth";
import SettingsCard from "../components/SettingsCard";
import SettingsTitle from "../components/SettingsTitle";
import { EditBrandColor } from "./components/EditBrandColor";
import { EditHighlightBorder } from "./components/EditHighlightBorder";
import { EditPlacement } from "./components/EditPlacement";
import { EditFormbricksBranding } from "./components/EditBranding";
export default async function ProfileSettingsPage({ params }: { params: { environmentId: string } }) {
const [session, team, product] = await Promise.all([
@@ -33,8 +32,10 @@ export default async function ProfileSettingsPage({ params }: { params: { enviro
throw new Error("Team not found");
}
const canRemoveLinkBranding = team.billing.features.linkSurvey.status !== "inactive";
const canRemoveInAppBranding = team.billing.features.inAppSurvey.status !== "inactive";
const currentUserMembership = await getMembershipByUserIdTeamId(session?.user.id, team.id);
const canRemoveSignature = team.billing.features.linkSurvey.status !== "inactive";
const { isDeveloper, isViewer } = getAccessFlags(currentUserMembership?.role);
const isBrandColorEditDisabled = isDeveloper ? true : isViewer;
@@ -68,11 +69,18 @@ export default async function ProfileSettingsPage({ params }: { params: { enviro
/>
</SettingsCard>
<SettingsCard
title="Formbricks Signature"
title="Formbricks Branding"
description="We love your support but understand if you toggle it off.">
<EditFormbricksSignature
<EditFormbricksBranding
type="linkSurvey"
product={product}
canRemoveSignature={canRemoveSignature}
canRemoveBranding={canRemoveLinkBranding}
environmentId={params.environmentId}
/>
<EditFormbricksBranding
type="inAppSurvey"
product={product}
canRemoveBranding={canRemoveInAppBranding}
environmentId={params.environmentId}
/>
</SettingsCard>

View File

@@ -14,7 +14,7 @@ import { getProfile } from "@formbricks/lib/profile/service";
export default async function ProfileSettingsPage({ params }: { params: { environmentId: string } }) {
const { environmentId } = params;
const session = await getServerSession(authOptions);
const profile = session ? await getProfile(session.user.id) : null;
const profile = session && session.user ? await getProfile(session.user.id) : null;
return (
<>

View File

@@ -108,7 +108,7 @@ if (typeof window !== "undefined") {
</p>
<CodeBlock language="js">{`<!-- START Formbricks Surveys -->
<script type="text/javascript">
!function(){var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src="https://unpkg.com/@formbricks/js@^1.1.2/dist/index.umd.js";var e=document.getElementsByTagName("script")[0];e.parentNode.insertBefore(t,e),setTimeout(function(){window.formbricks.init({environmentId: "${environmentId}", apiHost: "${window.location.protocol}//${window.location.host}"})},500)}();
!function(){var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src="https://unpkg.com/@formbricks/js@^1.2.0/dist/index.umd.js";var e=document.getElementsByTagName("script")[0];e.parentNode.insertBefore(t,e),setTimeout(function(){window.formbricks.init({environmentId: "${environmentId}", apiHost: "${window.location.protocol}//${window.location.host}"})},500)}();
</script>
<!-- END Formbricks Surveys -->`}</CodeBlock>
<p className="text-lg font-semibold text-slate-800">You&apos;re done 🎉</p>

View File

@@ -1,5 +1,6 @@
"use server";
import { getEmailTemplateHtml } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/emailTemplate";
import { generateSurveySingleUseId } from "@/app/lib/singleUseSurveys";
import { authOptions } from "@formbricks/lib/authOptions";
import { sendEmbedSurveyPreviewEmail } from "@formbricks/lib/emails/emails";
@@ -32,3 +33,13 @@ export const sendEmailAction = async ({ html, subject, to }: TSendEmailActionArg
}
return await sendEmbedSurveyPreviewEmail(to, subject, html);
};
export const getEmailHtmlAction = async (surveyId: string) => {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
const hasUserSurveyAccess = await canUserAccessSurvey(session.user.id, surveyId);
if (!hasUserSurveyAccess) throw new AuthorizationError("Not authorized");
return await getEmailTemplateHtml(surveyId);
};

View File

@@ -1,17 +1,17 @@
"use client";
import LinkTab from "./shareEmbedTabs/LinkTab";
import EmailTab from "./shareEmbedTabs/EmailTab";
import WebpageTab from "./shareEmbedTabs/WebpageTab";
import LinkSingleUseSurveyModal from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/LinkSingleUseSurveyModal";
import { useMemo, useState } from "react";
import { TProduct } from "@formbricks/types/product";
import { TSurvey } from "@formbricks/types/surveys";
import { cn } from "@formbricks/lib/cn";
import { DialogContent, Dialog } from "@formbricks/ui/Dialog";
import { Button } from "@formbricks/ui/Button";
import { LinkIcon, EnvelopeIcon, CodeBracketIcon } from "@heroicons/react/24/outline";
import { TProduct } from "@formbricks/types/product";
import { TProfile } from "@formbricks/types/profile";
import { TSurvey } from "@formbricks/types/surveys";
import { Button } from "@formbricks/ui/Button";
import { Dialog, DialogContent } from "@formbricks/ui/Dialog";
import { CodeBracketIcon, EnvelopeIcon, LinkIcon } from "@heroicons/react/24/outline";
import { useMemo, useState } from "react";
import EmailTab from "./shareEmbedTabs/EmailTab";
import LinkTab from "./shareEmbedTabs/LinkTab";
import WebpageTab from "./shareEmbedTabs/WebpageTab";
interface ShareEmbedSurveyProps {
survey: TSurvey;
@@ -43,16 +43,6 @@ export default function ShareEmbedSurvey({
const [activeId, setActiveId] = useState(tabs[0].id);
const componentMap = {
link: isSingleUseLinkSurvey ? (
<LinkSingleUseSurveyModal survey={survey} surveyBaseUrl={webAppUrl} />
) : (
<LinkTab surveyUrl={surveyUrl} survey={survey} brandColor={surveyBrandColor} />
),
email: <EmailTab survey={survey} surveyUrl={surveyUrl} email={email} brandColor={surveyBrandColor} />,
webpage: <WebpageTab surveyUrl={surveyUrl} />,
};
return (
<Dialog
open={open}
@@ -87,7 +77,15 @@ export default function ShareEmbedSurvey({
</div>
<div className="flex w-full grow flex-col gap-6 bg-gray-50 px-4 py-6 lg:p-6">
<div className="flex h-full overflow-y-scroll lg:h-[590px] lg:overflow-y-visible">
{componentMap[activeId]}
{isSingleUseLinkSurvey ? (
<LinkSingleUseSurveyModal survey={survey} surveyBaseUrl={webAppUrl} />
) : activeId === "link" ? (
<LinkTab surveyUrl={surveyUrl} survey={survey} brandColor={surveyBrandColor} />
) : activeId === "email" ? (
<EmailTab surveyId={survey.id} email={email} />
) : activeId === "webpage" ? (
<WebpageTab surveyUrl={surveyUrl} />
) : null}
</div>
<div className="mx-auto flex max-w-max rounded-md bg-slate-100 p-1 lg:hidden">
{tabs.slice(0, 2).map((tab) => (

View File

@@ -1,59 +1,55 @@
"use client";
import { cn } from "@formbricks/lib/cn";
import { Button } from "@formbricks/ui/Button";
import { Input } from "@formbricks/ui/Input";
import { TSurveyQuestionType } from "@formbricks/types/surveys";
import { TSurvey } from "@formbricks/types/surveys";
import { AuthenticationError } from "@formbricks/types/errors";
import { sendEmailAction } from "../../actions";
import { Button } from "@formbricks/ui/Button";
import CodeBlock from "@formbricks/ui/CodeBlock";
import { CodeBracketIcon, DocumentDuplicateIcon, EnvelopeIcon } from "@heroicons/react/24/solid";
import {
Column,
Container,
Button as EmailButton,
Link,
Row,
Section,
Tailwind,
Text,
render,
Img,
} from "@react-email/components";
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import toast from "react-hot-toast";
import { isLight } from "@/app/lib/utils";
import { getEmailHtmlAction, sendEmailAction } from "../../actions";
import LoadingSpinner from "@formbricks/ui/LoadingSpinner";
interface EmailTabProps {
survey: TSurvey;
surveyUrl: string;
surveyId: string;
email: string;
brandColor: string;
}
export default function EmailTab({ survey, surveyUrl, email, brandColor }: EmailTabProps) {
export default function EmailTab({ surveyId, email }: EmailTabProps) {
const [showEmbed, setShowEmbed] = useState(false);
const [emailHtmlPreview, setEmailHtmlPreview] = useState<string>("");
const emailHtml = useMemo(() => {
if (!emailHtmlPreview) return "";
return emailHtmlPreview
.replaceAll("?preview=true&amp;", "?")
.replaceAll("?preview=true&;", "?")
.replaceAll("?preview=true", "");
}, [emailHtmlPreview]);
useEffect(() => {
getData();
async function getData() {
const emailHtml = await getEmailHtmlAction(surveyId);
setEmailHtmlPreview(emailHtml);
}
});
const subject = "Formbricks Email Survey Preview";
const emailValues = useMemo(() => {
return getEmailValues({ brandColor, survey, surveyUrl, preview: false });
}, []);
const previewEmailValues = useMemo(() => {
return getEmailValues({ brandColor, survey, surveyUrl, preview: true });
}, []);
const sendPreviewEmail = async () => {
const sendPreviewEmail = async (html) => {
try {
await sendEmailAction({ html: previewEmailValues.html, subject, to: email });
await sendEmailAction({
html,
subject,
to: email,
});
toast.success("Email sent!");
} catch (err) {
if (err instanceof AuthenticationError) {
toast.error("You are not authenticated to perform this action.");
return;
}
toast.error("Something went wrong. Please try again later.");
}
};
@@ -68,7 +64,7 @@ export default function EmailTab({ survey, surveyUrl, email, brandColor }: Email
aria-label="Embed survey in your website"
onClick={() => {
toast.success("Embed code copied to clipboard!");
navigator.clipboard.writeText(emailValues.html);
navigator.clipboard.writeText(emailHtml);
}}
className="shrink-0"
EndIcon={DocumentDuplicateIcon}>
@@ -76,12 +72,11 @@ export default function EmailTab({ survey, surveyUrl, email, brandColor }: Email
</Button>
) : (
<>
<Input type="email" placeholder="user@mail.com" className="h-11 grow bg-white" value={email} />
<Button
variant="secondary"
title="send preview email"
aria-label="send preview email"
onClick={sendPreviewEmail}
onClick={() => sendPreviewEmail(emailHtmlPreview)}
EndIcon={EnvelopeIcon}
className="shrink-0">
Send Preview
@@ -92,7 +87,9 @@ export default function EmailTab({ survey, surveyUrl, email, brandColor }: Email
variant="darkCTA"
title="view embed code for email"
aria-label="view embed code for email"
onClick={() => setShowEmbed(!showEmbed)}
onClick={() => {
setShowEmbed(!showEmbed);
}}
EndIcon={CodeBracketIcon}
className="shrink-0">
{showEmbed ? "Hide Embed Code" : "View Embed Code"}
@@ -104,10 +101,10 @@ export default function EmailTab({ survey, surveyUrl, email, brandColor }: Email
customCodeClass="!whitespace-normal sm:!whitespace-pre-wrap !break-all sm:!break-normal"
language="html"
showCopyToClipboard={false}>
{emailValues.html}
{emailHtml}
</CodeBlock>
) : (
<div className="">
<div>
<div className="mb-6 flex gap-2">
<div className="h-3 w-3 rounded-full bg-red-500"></div>
<div className="h-3 w-3 rounded-full bg-amber-500"></div>
@@ -118,7 +115,13 @@ export default function EmailTab({ survey, surveyUrl, email, brandColor }: Email
To : {email || "user@mail.com"}
</div>
<div className="border-b border-slate-200 pb-2 text-sm">Subject : {subject}</div>
<div className="p-4">{previewEmailValues.Component}</div>
<div className="p-4">
{emailHtml ? (
<div dangerouslySetInnerHTML={{ __html: emailHtmlPreview }}></div>
) : (
<LoadingSpinner />
)}
</div>
</div>
</div>
)}
@@ -126,308 +129,3 @@ export default function EmailTab({ survey, surveyUrl, email, brandColor }: Email
</div>
);
}
const getEmailValues = ({ survey, surveyUrl, brandColor, preview }) => {
const doctype =
'<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">';
const Template = getEmailTemplate(survey, surveyUrl, brandColor, preview);
const html = render(Template, { pretty: true });
const htmlWithoutDoctype = html.replace(doctype, "");
return { Component: Template, html: htmlWithoutDoctype };
};
const getEmailTemplate = (survey: TSurvey, surveyUrl: string, brandColor: string, preview: boolean) => {
const url = preview ? `${surveyUrl}?preview=true` : surveyUrl;
const urlWithPrefilling = preview ? `${surveyUrl}?preview=true&` : `${surveyUrl}?`;
const firstQuestion = survey.questions[0];
switch (firstQuestion.type) {
case TSurveyQuestionType.OpenText:
return (
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
<Text className="m-0 mr-8 block p-0 text-base font-semibold leading-6 text-slate-800">
{firstQuestion.headline}
</Text>
<Text className="m-0 block p-0 text-sm font-normal leading-6 text-slate-500">
{firstQuestion.subheader}
</Text>
<Section className="mt-4 block h-20 w-full rounded-lg border border-solid border-gray-200 bg-slate-50" />
<EmailFooter />
</EmailTemplateWrapper>
);
case TSurveyQuestionType.Consent:
return (
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
<Text className="m-0 block text-base font-semibold leading-6 text-slate-800">
{firstQuestion.headline}
</Text>
<Container className="m-0 text-sm font-normal leading-6 text-slate-500">
<Text className="m-0 p-0" dangerouslySetInnerHTML={{ __html: firstQuestion.html || "" }}></Text>
</Container>
<Container className="m-0 mt-4 block w-full max-w-none rounded-lg border border-solid border-gray-200 bg-slate-50 p-4 font-medium text-slate-800">
<Text className="m-0 inline-block">{firstQuestion.label}</Text>
</Container>
<Container className="mx-0 mt-4 flex max-w-none justify-end">
{!firstQuestion.required && (
<EmailButton
href={`${urlWithPrefilling}${firstQuestion.id}=dismissed`}
className="inline-flex cursor-pointer appearance-none rounded-md px-6 py-3 text-sm font-medium text-black">
Reject
</EmailButton>
)}
<EmailButton
href={`${urlWithPrefilling}${firstQuestion.id}=accepted`}
className={cn(
"bg-brand-color ml-2 inline-flex cursor-pointer appearance-none rounded-md px-6 py-3 text-sm font-medium",
isLight(brandColor) ? "text-black" : "text-white"
)}>
Accept
</EmailButton>
</Container>
<EmailFooter />
</EmailTemplateWrapper>
);
case TSurveyQuestionType.NPS:
return (
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
<Section>
<Text className="m-0 block text-base font-semibold leading-6 text-slate-800">
{firstQuestion.headline}
</Text>
<Text className="m-0 block p-0 text-sm font-normal leading-6 text-slate-500">
{firstQuestion.subheader}
</Text>
<Container className="mx-0 mt-4 flex w-max flex-col">
<Section className="block overflow-hidden rounded-md border border-gray-200">
{Array.from({ length: 11 }, (_, i) => (
<EmailButton
key={i}
href={`${urlWithPrefilling}${firstQuestion.id}=${i}`}
className="m-0 inline-flex h-10 w-10 items-center justify-center border-gray-200 p-0 text-slate-800">
{i}
</EmailButton>
))}
</Section>
<Section className="mt-2 px-1.5 text-xs leading-6 text-slate-500">
<Row>
<Column>
<Text className="m-0 inline-block w-max p-0">{firstQuestion.lowerLabel}</Text>
</Column>
<Column className="text-right">
<Text className="m-0 inline-block w-max p-0 text-right">{firstQuestion.upperLabel}</Text>
</Column>
</Row>
</Section>
</Container>
{/* {!firstQuestion.required && (
<EmailButton
href={`${urlWithPrefilling}${firstQuestion.id}=dismissed`}
className={cn(
"bg-brand-color mt-4 cursor-pointer appearance-none rounded-md px-6 py-3 text-sm font-medium",
isLight(brandColor) ? "text-black" : "text-white"
)}>
{firstQuestion.buttonLabel || "Skip"}
</EmailButton>
)} */}
<EmailFooter />
</Section>
</EmailTemplateWrapper>
);
case TSurveyQuestionType.CTA:
return (
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
<Text className="m-0 block text-base font-semibold leading-6 text-slate-800">
{firstQuestion.headline}
</Text>
<Container className="mt-2 text-sm font-normal leading-6 text-slate-500">
<Text className="m-0 p-0" dangerouslySetInnerHTML={{ __html: firstQuestion.html || "" }}></Text>
</Container>
<Container className="mx-0 mt-4 max-w-none">
{!firstQuestion.required && (
<EmailButton
href={`${urlWithPrefilling}${firstQuestion.id}=dismissed`}
className="inline-flex cursor-pointer appearance-none rounded-md px-6 py-3 text-sm font-medium text-black">
{firstQuestion.dismissButtonLabel || "Skip"}
</EmailButton>
)}
<EmailButton
href={`${urlWithPrefilling}${firstQuestion.id}=clicked`}
className={cn(
"bg-brand-color inline-flex cursor-pointer appearance-none rounded-md px-6 py-3 text-sm font-medium",
isLight(brandColor) ? "text-black" : "text-white"
)}>
{firstQuestion.buttonLabel}
</EmailButton>
</Container>
<EmailFooter />
</EmailTemplateWrapper>
);
case TSurveyQuestionType.Rating:
return (
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
<Section>
<Text className="m-0 block text-base font-semibold leading-6 text-slate-800">
{firstQuestion.headline}
</Text>
<Text className="m-0 block p-0 text-sm font-normal leading-6 text-slate-500">
{firstQuestion.subheader}
</Text>
<Container className="mx-0 mt-4 flex">
<Section
className={cn("inline-block w-max overflow-hidden rounded-md", {
["border border-solid border-gray-200"]: firstQuestion.scale === "number",
})}>
{Array.from({ length: firstQuestion.range }, (_, i) => (
<EmailButton
key={i}
href={`${urlWithPrefilling}${firstQuestion.id}=${i + 1}`}
className={cn(
"m-0 inline-flex h-16 w-16 items-center justify-center p-0 text-slate-800",
{
["border border-solid border-gray-200"]: firstQuestion.scale === "number",
}
)}>
{firstQuestion.scale === "smiley" && <Text className="text-3xl">😃</Text>}
{firstQuestion.scale === "number" && i + 1}
{firstQuestion.scale === "star" && <Text className="text-3xl"></Text>}
</EmailButton>
))}
</Section>
<Section className="m-0 px-1.5 text-xs leading-6 text-slate-500">
<Row>
<Column>
<Text className="m-0 inline-block p-0">{firstQuestion.lowerLabel}</Text>
</Column>
<Column className="text-right">
<Text className="m-0 inline-block p-0 text-right">{firstQuestion.upperLabel}</Text>
</Column>
</Row>
</Section>
</Container>
{/* {!firstQuestion.required && (
<EmailButton
href={`${urlWithPrefilling}${firstQuestion.id}=dismissed`}
className={cn(
"bg-brand-color mt-4 cursor-pointer appearance-none rounded-md px-6 py-3 text-sm font-medium",
isLight(brandColor) ? "text-black" : "text-white"
)}>
{firstQuestion.buttonLabel || "Skip"}
</EmailButton>
)} */}
<EmailFooter />
</Section>
</EmailTemplateWrapper>
);
case TSurveyQuestionType.MultipleChoiceMulti:
return (
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
<Text className="m-0 mr-8 block p-0 text-base font-semibold leading-6 text-slate-800">
{firstQuestion.headline}
</Text>
<Text className="m-0 mb-2 block p-0 text-sm font-normal leading-6 text-slate-500">
{firstQuestion.subheader}
</Text>
<Container className="mx-0 max-w-none">
{firstQuestion.choices.map((choice) => (
<Section
className="mt-2 block w-full rounded-lg border border-solid border-gray-200 bg-slate-50 p-4 text-slate-800"
key={choice.id}>
{choice.label}
</Section>
))}
</Container>
<EmailFooter />
</EmailTemplateWrapper>
);
case TSurveyQuestionType.MultipleChoiceSingle:
return (
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
<Text className="m-0 mr-8 block p-0 text-base font-semibold leading-6 text-slate-800">
{firstQuestion.headline}
</Text>
<Text className="m-0 mb-2 block p-0 text-sm font-normal leading-6 text-slate-500">
{firstQuestion.subheader}
</Text>
<Container className="mx-0 max-w-none">
{firstQuestion.choices
.filter((choice) => choice.id !== "other")
.map((choice) => (
<Link
key={choice.id}
className="mt-2 block rounded-lg border border-solid border-gray-200 bg-slate-50 p-4 text-slate-800 hover:bg-slate-100"
href={`${urlWithPrefilling}${firstQuestion.id}=${choice.label}`}>
{choice.label}
</Link>
))}
</Container>
<EmailFooter />
</EmailTemplateWrapper>
);
case TSurveyQuestionType.PictureSelection:
return (
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
<Text className="m-0 mr-8 block p-0 text-base font-semibold leading-6 text-slate-800">
{firstQuestion.headline}
</Text>
<Text className="m-0 mb-2 block p-0 text-sm font-normal leading-6 text-slate-500">
{firstQuestion.subheader}
</Text>
<Section className="mx-0">
{firstQuestion.choices.map((choice) =>
firstQuestion.allowMulti ? (
<Img
src={choice.imageUrl}
className="mb-1 mr-1 inline-block h-[110px] w-[220px] rounded-lg"
/>
) : (
<Link
href={`${urlWithPrefilling}${firstQuestion.id}=${choice.id}`}
target="_blank"
className="mb-1 mr-1 inline-block h-[110px] w-[220px] rounded-lg">
<Img src={choice.imageUrl} className="h-full w-full rounded-lg" />
</Link>
)
)}
</Section>
<EmailFooter />
</EmailTemplateWrapper>
);
}
};
const EmailTemplateWrapper = ({ children, surveyUrl, brandColor }) => {
return (
<Tailwind
config={{
theme: {
extend: {
colors: {
"brand-color": brandColor,
},
},
},
}}>
<Link
href={surveyUrl}
target="_blank"
className="mx-0 my-2 block rounded-lg border border-solid border-slate-300 bg-white p-8 font-sans text-inherit">
{children}
</Link>
</Tailwind>
);
};
const EmailFooter = () => {
return (
<Container className="m-auto mt-8 text-center ">
<Link href="https://formbricks.com/" target="_blank" className="text-xs text-slate-400">
Powered by Formbricks
</Link>
</Container>
);
};

View File

@@ -56,7 +56,7 @@ export default function LinkTab({ surveyUrl, survey, brandColor }: EmailTabProps
<SurveyInline
brandColor={brandColor}
survey={survey}
formbricksSignature={false}
isBrandingEnabled={false}
autoFocus={false}
isRedirectDisabled={false}
key={survey.id}

View File

@@ -0,0 +1,319 @@
import { cn } from "@formbricks/lib/cn";
import { TSurvey, TSurveyQuestionType } from "@formbricks/types/surveys";
import { isLight } from "@/app/lib/utils";
import { WEBAPP_URL } from "@formbricks/lib/constants";
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
import { getSurvey } from "@formbricks/lib/survey/service";
import {
Column,
Container,
Button as EmailButton,
Img,
Link,
Row,
Section,
Tailwind,
Text,
} from "@react-email/components";
import { render } from "@react-email/render";
interface EmailTemplateProps {
survey: TSurvey;
surveyUrl: string;
brandColor: string;
}
export const getEmailTemplateHtml = async (surveyId) => {
const survey = await getSurvey(surveyId);
if (!survey) {
throw new Error("Survey not found");
}
const product = await getProductByEnvironmentId(survey.environmentId);
if (!product) {
throw new Error("Product not found");
}
const brandColor = product.brandColor;
const surveyUrl = WEBAPP_URL + "/s/" + survey.id;
const html = render(<EmailTemplate survey={survey} surveyUrl={surveyUrl} brandColor={brandColor} />, {
pretty: true,
});
const doctype =
'<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">';
const htmlCleaned = html.toString().replace(doctype, "");
return htmlCleaned;
};
const EmailTemplate = ({ survey, surveyUrl, brandColor }: EmailTemplateProps) => {
const url = `${surveyUrl}?preview=true`;
const urlWithPrefilling = `${surveyUrl}?preview=true&`;
const firstQuestion = survey.questions[0];
switch (firstQuestion.type) {
case TSurveyQuestionType.OpenText:
return (
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
<Text className="m-0 mr-8 block p-0 text-base font-semibold leading-6 text-slate-800">
{firstQuestion.headline}
</Text>
<Text className="m-0 block p-0 text-sm font-normal leading-6 text-slate-500">
{firstQuestion.subheader}
</Text>
<Section className="mt-4 block h-20 w-full rounded-lg border border-solid border-gray-200 bg-slate-50" />
<EmailFooter />
</EmailTemplateWrapper>
);
case TSurveyQuestionType.Consent:
return (
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
<Text className="m-0 block text-base font-semibold leading-6 text-slate-800">
{firstQuestion.headline}
</Text>
<Container className="m-0 text-sm font-normal leading-6 text-slate-500">
<Text className="m-0 p-0" dangerouslySetInnerHTML={{ __html: firstQuestion.html || "" }}></Text>
</Container>
<Container className="m-0 mt-4 block w-full max-w-none rounded-lg border border-solid border-gray-200 bg-slate-50 p-4 font-medium text-slate-800">
<Text className="m-0 inline-block">{firstQuestion.label}</Text>
</Container>
<Container className="mx-0 mt-4 flex max-w-none justify-end">
{!firstQuestion.required && (
<EmailButton
href={`${urlWithPrefilling}${firstQuestion.id}=dismissed`}
className="inline-flex cursor-pointer appearance-none rounded-md px-6 py-3 text-sm font-medium text-black">
Reject
</EmailButton>
)}
<EmailButton
href={`${urlWithPrefilling}${firstQuestion.id}=accepted`}
className={cn(
"bg-brand-color ml-2 inline-flex cursor-pointer appearance-none rounded-md px-6 py-3 text-sm font-medium",
isLight(brandColor) ? "text-black" : "text-white"
)}>
Accept
</EmailButton>
</Container>
<EmailFooter />
</EmailTemplateWrapper>
);
case TSurveyQuestionType.NPS:
return (
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
<Section>
<Text className="m-0 block text-base font-semibold leading-6 text-slate-800">
{firstQuestion.headline}
</Text>
<Text className="m-0 block p-0 text-sm font-normal leading-6 text-slate-500">
{firstQuestion.subheader}
</Text>
<Container className="mx-0 mt-4 flex w-max flex-col">
<Section className="block overflow-hidden rounded-md border border-gray-200">
{Array.from({ length: 11 }, (_, i) => (
<EmailButton
key={i}
href={`${urlWithPrefilling}${firstQuestion.id}=${i}`}
className="m-0 inline-flex h-10 w-10 items-center justify-center border-gray-200 p-0 text-slate-800">
{i}
</EmailButton>
))}
</Section>
<Section className="mt-2 px-1.5 text-xs leading-6 text-slate-500">
<Row>
<Column>
<Text className="m-0 inline-block w-max p-0">{firstQuestion.lowerLabel}</Text>
</Column>
<Column className="text-right">
<Text className="m-0 inline-block w-max p-0 text-right">{firstQuestion.upperLabel}</Text>
</Column>
</Row>
</Section>
</Container>
<EmailFooter />
</Section>
</EmailTemplateWrapper>
);
case TSurveyQuestionType.CTA:
return (
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
<Text className="m-0 block text-base font-semibold leading-6 text-slate-800">
{firstQuestion.headline}
</Text>
<Container className="mt-2 text-sm font-normal leading-6 text-slate-500">
<Text className="m-0 p-0" dangerouslySetInnerHTML={{ __html: firstQuestion.html || "" }}></Text>
</Container>
<Container className="mx-0 mt-4 max-w-none">
{!firstQuestion.required && (
<EmailButton
href={`${urlWithPrefilling}${firstQuestion.id}=dismissed`}
className="inline-flex cursor-pointer appearance-none rounded-md px-6 py-3 text-sm font-medium text-black">
{firstQuestion.dismissButtonLabel || "Skip"}
</EmailButton>
)}
<EmailButton
href={`${urlWithPrefilling}${firstQuestion.id}=clicked`}
className={cn(
"bg-brand-color inline-flex cursor-pointer appearance-none rounded-md px-6 py-3 text-sm font-medium",
isLight(brandColor) ? "text-black" : "text-white"
)}>
{firstQuestion.buttonLabel}
</EmailButton>
</Container>
<EmailFooter />
</EmailTemplateWrapper>
);
case TSurveyQuestionType.Rating:
return (
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
<Section>
<Text className="m-0 block text-base font-semibold leading-6 text-slate-800">
{firstQuestion.headline}
</Text>
<Text className="m-0 block p-0 text-sm font-normal leading-6 text-slate-500">
{firstQuestion.subheader}
</Text>
<Container className="mx-0 mt-4 flex">
<Section
className={cn("inline-block w-max overflow-hidden rounded-md", {
["border border-solid border-gray-200"]: firstQuestion.scale === "number",
})}>
{Array.from({ length: firstQuestion.range }, (_, i) => (
<EmailButton
key={i}
href={`${urlWithPrefilling}${firstQuestion.id}=${i + 1}`}
className={cn(
"m-0 inline-flex h-16 w-16 items-center justify-center p-0 text-slate-800",
{
["border border-solid border-gray-200"]: firstQuestion.scale === "number",
}
)}>
{firstQuestion.scale === "smiley" && <Text className="text-3xl">😃</Text>}
{firstQuestion.scale === "number" && i + 1}
{firstQuestion.scale === "star" && <Text className="text-3xl"></Text>}
</EmailButton>
))}
</Section>
<Section className="m-0 px-1.5 text-xs leading-6 text-slate-500">
<Row>
<Column>
<Text className="m-0 inline-block p-0">{firstQuestion.lowerLabel}</Text>
</Column>
<Column className="text-right">
<Text className="m-0 inline-block p-0 text-right">{firstQuestion.upperLabel}</Text>
</Column>
</Row>
</Section>
</Container>
<EmailFooter />
</Section>
</EmailTemplateWrapper>
);
case TSurveyQuestionType.MultipleChoiceMulti:
return (
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
<Text className="m-0 mr-8 block p-0 text-base font-semibold leading-6 text-slate-800">
{firstQuestion.headline}
</Text>
<Text className="m-0 mb-2 block p-0 text-sm font-normal leading-6 text-slate-500">
{firstQuestion.subheader}
</Text>
<Container className="mx-0 max-w-none">
{firstQuestion.choices.map((choice) => (
<Section
className="mt-2 block w-full rounded-lg border border-solid border-gray-200 bg-slate-50 p-4 text-slate-800"
key={choice.id}>
{choice.label}
</Section>
))}
</Container>
<EmailFooter />
</EmailTemplateWrapper>
);
case TSurveyQuestionType.MultipleChoiceSingle:
return (
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
<Text className="m-0 mr-8 block p-0 text-base font-semibold leading-6 text-slate-800">
{firstQuestion.headline}
</Text>
<Text className="m-0 mb-2 block p-0 text-sm font-normal leading-6 text-slate-500">
{firstQuestion.subheader}
</Text>
<Container className="mx-0 max-w-none">
{firstQuestion.choices
.filter((choice) => choice.id !== "other")
.map((choice) => (
<Link
key={choice.id}
className="mt-2 block rounded-lg border border-solid border-gray-200 bg-slate-50 p-4 text-slate-800 hover:bg-slate-100"
href={`${urlWithPrefilling}${firstQuestion.id}=${choice.label}`}>
{choice.label}
</Link>
))}
</Container>
<EmailFooter />
</EmailTemplateWrapper>
);
case TSurveyQuestionType.PictureSelection:
return (
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
<Text className="m-0 mr-8 block p-0 text-base font-semibold leading-6 text-slate-800">
{firstQuestion.headline}
</Text>
<Text className="m-0 mb-2 block p-0 text-sm font-normal leading-6 text-slate-500">
{firstQuestion.subheader}
</Text>
<Section className="mx-0">
{firstQuestion.choices.map((choice) =>
firstQuestion.allowMulti ? (
<Img
src={choice.imageUrl}
className="mb-1 mr-1 inline-block h-[110px] w-[220px] rounded-lg"
/>
) : (
<Link
href={`${urlWithPrefilling}${firstQuestion.id}=${choice.id}`}
target="_blank"
className="mb-1 mr-1 inline-block h-[110px] w-[220px] rounded-lg">
<Img src={choice.imageUrl} className="h-full w-full rounded-lg" />
</Link>
)
)}
</Section>
<EmailFooter />
</EmailTemplateWrapper>
);
}
};
const EmailTemplateWrapper = ({ children, surveyUrl, brandColor }) => {
return (
<Tailwind
config={{
theme: {
extend: {
colors: {
"brand-color": brandColor,
},
},
},
}}>
<Link
href={surveyUrl}
target="_blank"
className="mx-0 my-2 block rounded-lg border border-solid border-slate-300 bg-white p-8 font-sans text-inherit">
{children}
</Link>
</Tailwind>
);
};
const EmailFooter = () => {
return (
<Container className="m-auto mt-8 text-center ">
<Link href="https://formbricks.com/" target="_blank" className="text-xs text-slate-400">
Powered by Formbricks
</Link>
</Container>
);
};

View File

@@ -38,6 +38,7 @@ export default function CTAQuestionForm({
question={question}
questionIdx={questionIdx}
updateQuestion={updateQuestion}
localSurvey={localSurvey}
/>
<div className="mt-3">

View File

@@ -34,6 +34,7 @@ export default function ConsentQuestionForm({
question={question}
questionIdx={questionIdx}
updateQuestion={updateQuestion}
localSurvey={localSurvey}
/>
<div className="mt-3">

View File

@@ -157,7 +157,7 @@ export default function EditWelcomeCard({
</div>
</div>
</div>
{/* <div className="mt-8 flex items-center">
<div className="mt-8 flex items-center">
<div className="mr-2">
<Switch
id="timeToFinish"
@@ -176,7 +176,7 @@ export default function EditWelcomeCard({
Display an estimate of completion time for survey
</div>
</div>
</div> */}
</div>
</form>
</Collapsible.CollapsibleContent>
</Collapsible.Root>

View File

@@ -49,7 +49,7 @@ export default function HowToSendCard({ localSurvey, setLocalSurvey, environment
const options = [
{
id: "web",
name: "Web App",
name: "In-App Survey",
icon: ComputerDesktopIcon,
description: "Embed a survey in your web app to collect responses.",
comingSoon: false,
@@ -65,7 +65,7 @@ export default function HowToSendCard({ localSurvey, setLocalSurvey, environment
},
{
id: "mobile",
name: "Mobile app",
name: "Mobile App Survey",
icon: DevicePhoneMobileIcon,
description: "Survey users inside a mobile app (iOS & Android).",
comingSoon: true,

View File

@@ -170,6 +170,7 @@ export default function MultipleChoiceMultiForm({
question={question}
questionIdx={questionIdx}
updateQuestion={updateQuestion}
localSurvey={localSurvey}
/>
<div className="mt-3">

View File

@@ -170,6 +170,7 @@ export default function MultipleChoiceSingleForm({
question={question}
questionIdx={questionIdx}
updateQuestion={updateQuestion}
localSurvey={localSurvey}
/>
<div className="mt-3">

View File

@@ -36,6 +36,7 @@ export default function NPSQuestionForm({
question={question}
questionIdx={questionIdx}
updateQuestion={updateQuestion}
localSurvey={localSurvey}
/>
<div className="mt-3">

View File

@@ -59,6 +59,7 @@ export default function OpenQuestionForm({
question={question}
questionIdx={questionIdx}
updateQuestion={updateQuestion}
localSurvey={localSurvey}
/>
<div className="mt-3">

View File

@@ -38,6 +38,7 @@ export default function PictureSelectionForm({
question={question}
questionIdx={questionIdx}
updateQuestion={updateQuestion}
localSurvey={localSurvey}
/>
<div className="mt-3">
{showSubheader && (

View File

@@ -1,10 +1,10 @@
"use client";
import { cn } from "@formbricks/lib/cn";
import { RadioGroup, RadioGroupItem } from "@formbricks/ui/RadioGroup";
import { Label } from "@formbricks/ui/Label";
import { getPlacementStyle } from "@/app/lib/preview";
import { cn } from "@formbricks/lib/cn";
import { TPlacement } from "@formbricks/types/common";
import { Label } from "@formbricks/ui/Label";
import { RadioGroup, RadioGroupItem } from "@formbricks/ui/RadioGroup";
const placements = [
{ name: "Bottom Right", value: "bottomRight", disabled: false },
{ name: "Top Right", value: "topRight", disabled: false },
@@ -18,8 +18,8 @@ type TPlacementProps = {
setCurrentPlacement: (placement: TPlacement) => void;
setOverlay: (overlay: string) => void;
overlay: string;
setClickOutside: (clickOutside: boolean) => void;
clickOutside: boolean;
setClickOutsideClose: (clickOutside: boolean) => void;
clickOutsideClose: boolean;
};
export default function Placement({
@@ -27,8 +27,8 @@ export default function Placement({
currentPlacement,
setOverlay,
overlay,
setClickOutside,
clickOutside,
setClickOutsideClose,
clickOutsideClose,
}: TPlacementProps) {
return (
<>
@@ -78,8 +78,8 @@ export default function Placement({
<div className="mt-6 space-y-2">
<Label className="font-semibold">Allow users to exit by clicking outside the study</Label>
<RadioGroup
onValueChange={(value) => setClickOutside(value === "allow")}
value={clickOutside ? "allow" : "disallow"}
onValueChange={(value) => setClickOutsideClose(value === "allow")}
value={clickOutsideClose ? "allow" : "disallow"}
className="flex space-x-4">
<div className="flex items-center space-x-2 whitespace-nowrap">
<RadioGroupItem id="disallow" value="disallow" />

View File

@@ -1,11 +1,12 @@
"use client";
import { TSurveyQuestion } from "@formbricks/types/surveys";
import { TSurveyQuestion, TSurvey } from "@formbricks/types/surveys";
import FileInput from "@formbricks/ui/FileInput";
import { Input } from "@formbricks/ui/Input";
// import { Input } from "@formbricks/ui/Input";
import { Label } from "@formbricks/ui/Label";
import { ImagePlusIcon } from "lucide-react";
import { RefObject, useState } from "react";
import { RefObject, useEffect, useState } from "react";
import { MentionsInput, Mention } from "react-mentions";
interface QuestionFormInputProps {
question: TSurveyQuestion;
@@ -14,17 +15,45 @@ interface QuestionFormInputProps {
isInValid: boolean;
environmentId: string;
ref?: RefObject<HTMLInputElement>;
localSurvey: TSurvey;
}
const QuestionFormInput = ({
question,
localSurvey,
questionIdx,
updateQuestion,
isInValid,
// isInValid,
environmentId,
ref,
}: QuestionFormInputProps) => {
}: // ref,
QuestionFormInputProps) => {
const [showImageUploader, setShowImageUploader] = useState<boolean>(!!question.imageUrl);
const [mentionDisplayString, setMentionDisplayString] = useState<string>(question.headline);
const [prevHeadline, setPreviousHeadline] = useState<string>("");
const [data, setData] = useState<
{
id: string;
display: string;
}[]
>();
useEffect(() => {
setData(
localSurvey.questions.map((q) => {
if (question.id !== q.id)
return {
id: q.id,
display: q.headline,
};
else {
return {
id: "",
display: "",
};
}
})
);
}, [localSurvey, question]);
return (
<div className="mt-3">
@@ -42,7 +71,7 @@ const QuestionFormInput = ({
/>
)}
<div className="flex items-center space-x-2">
<Input
{/* <Input
autoFocus
ref={ref}
id="headline"
@@ -50,7 +79,59 @@ const QuestionFormInput = ({
value={question.headline}
onChange={(e) => updateQuestion(questionIdx, { headline: e.target.value })}
isInvalid={isInValid && question.headline.trim() === ""}
/>
/> */}
<MentionsInput
autoFocus
// ref={ref}
id="headline"
name="headline"
value={mentionDisplayString}
onChange={(event, _, newPlainTextValue) => {
setPreviousHeadline(question.headline);
updateQuestion(questionIdx, {
headline: newPlainTextValue,
});
setMentionDisplayString(event.target.value);
}}
style={{
width: "100%",
border: "1px rgb(203 213 225) solid",
borderRadius: "4px",
textArea: {
border: "none",
marginBottom: "1rem",
},
suggestions: {
list: {
backgroundColor: "white",
fontSize: 14,
},
item: {
padding: "5px 15px",
"&focused": {
backgroundColor: "#cee4e5",
},
},
},
}}>
<Mention
data={data || []}
trigger="@"
appendSpaceOnAdd
markup="[__display__]"
displayTransform={(_, display: string) => display}
onAdd={(id: string) => {
updateQuestion(questionIdx, {
recallString: prevHeadline + "recall:" + id,
});
}}
style={{
backgroundColor: "#cee4e5",
padding: "0.2rem",
marginLeft: "0.5rem",
}}
/>
</MentionsInput>
<ImagePlusIcon
aria-label="Toggle image uploader"
className="ml-2 h-4 w-4 cursor-pointer text-slate-400 hover:text-slate-500"

View File

@@ -182,6 +182,8 @@ export default function QuestionsView({
setLocalSurvey(updatedSurvey);
};
console.log(localSurvey.questions);
return (
<div className="mt-12 px-5 py-4">
<div className="mb-5 flex flex-col gap-5">

View File

@@ -1,4 +1,4 @@
import { TSurvey, TSurveyRatingQuestion } from "@formbricks/types/surveys";
import { TSurvey, TSurveyRatingQuestion, TSurveyQuestions } from "@formbricks/types/surveys";
import QuestionFormInput from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionFormInput";
import { Button } from "@formbricks/ui/Button";
import { Input } from "@formbricks/ui/Input";
@@ -36,6 +36,7 @@ export default function RatingQuestionForm({
question={question}
questionIdx={questionIdx}
updateQuestion={updateQuestion}
localSurvey={localSurvey}
/>
<div className="mt-3">

View File

@@ -19,7 +19,8 @@ export default function StylingCard({ localSurvey, setLocalSurvey }: StylingCard
const [open, setOpen] = useState(false);
const { type, productOverwrites } = localSurvey;
const { brandColor, clickOutside, darkOverlay, placement, highlightBorderColor } = productOverwrites ?? {};
const { brandColor, clickOutsideClose, darkOverlay, placement, highlightBorderColor } =
productOverwrites ?? {};
const togglePlacement = () => {
setLocalSurvey({
@@ -93,12 +94,12 @@ export default function StylingCard({ localSurvey, setLocalSurvey }: StylingCard
});
};
const handleClickOutside = (clickOutside: boolean) => {
const handleClickOutsideClose = (clickOutsideClose: boolean) => {
setLocalSurvey({
...localSurvey,
productOverwrites: {
...localSurvey.productOverwrites,
clickOutside,
clickOutsideClose,
},
});
};
@@ -163,8 +164,8 @@ export default function StylingCard({ localSurvey, setLocalSurvey }: StylingCard
setCurrentPlacement={handlePlacementChange}
setOverlay={handleOverlay}
overlay={darkOverlay ? "dark" : "light"}
setClickOutside={handleClickOutside}
clickOutside={!!clickOutside}
setClickOutsideClose={handleClickOutsideClose}
clickOutsideClose={!!clickOutsideClose}
/>
</div>
</div>

View File

@@ -12,8 +12,8 @@ import { TSurvey } from "@formbricks/types/surveys";
import { TProduct } from "@formbricks/types/product";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import { TActionClass } from "@formbricks/types/actionClasses";
import { ErrorComponent } from "@formbricks/ui/ErrorComponent";
import { TMembershipRole } from "@formbricks/types/memberships";
import Loading from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/loading";
interface SurveyEditorProps {
survey: TSurvey;
@@ -59,7 +59,7 @@ export default function SurveyEditor({
}, [localSurvey?.type]);
if (!localSurvey) {
return <ErrorComponent />;
return <Loading />;
}
return (

View File

@@ -1,6 +1,6 @@
"use client";
import AddNoCodeActionModal from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/components/AddNoCodeActionModal";
import AddNoCodeActionModal from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/components/AddActionModal";
import { cn } from "@formbricks/lib/cn";
import { TActionClass } from "@formbricks/types/actionClasses";
import { TSurvey } from "@formbricks/types/surveys";

View File

@@ -3,12 +3,14 @@
import { cn } from "@formbricks/lib/cn";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import { TSurvey } from "@formbricks/types/surveys";
import { Alert, AlertDescription, AlertTitle } from "@formbricks/ui/Alert";
import { Badge } from "@formbricks/ui/Badge";
import { Button } from "@formbricks/ui/Button";
import { Input } from "@formbricks/ui/Input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@formbricks/ui/Select";
import { CheckCircleIcon, FunnelIcon, PlusIcon, TrashIcon, UserGroupIcon } from "@heroicons/react/24/solid";
import * as Collapsible from "@radix-ui/react-collapsible";
import { Info } from "lucide-react";
import { useEffect, useState } from "react"; /* */
const filterConditions = [
@@ -99,6 +101,24 @@ export default function WhoToSendCard({ localSurvey, setLocalSurvey, attributeCl
<Collapsible.CollapsibleContent className="">
<hr className="py-1 text-slate-600" />
<div className="mx-6 mb-4 mt-3">
<Alert variant="info">
<Info className="h-4 w-4" />
<AlertTitle>User Identification</AlertTitle>
<AlertDescription>
To target your audience you need to identify your users within your app. You can read more
about how to do this in our{" "}
<a
href="https://formbricks.com/docs/attributes/identify-users"
className="underline"
target="_blank">
docs
</a>
.
</AlertDescription>
</Alert>
</div>
<div className="mx-6 flex items-center rounded-lg border border-slate-200 p-4 text-slate-800">
<div>
{localSurvey.attributeFilters?.length === 0 ? (

View File

@@ -204,7 +204,7 @@ export default function PreviewSurvey({
survey={survey}
brandColor={brandColor}
activeQuestionId={activeQuestionId || undefined}
formbricksSignature={product.formbricksSignature}
isBrandingEnabled={product.linkSurveyBranding}
onActiveQuestionChange={setActiveQuestionId}
isRedirectDisabled={true}
/>
@@ -219,7 +219,7 @@ export default function PreviewSurvey({
survey={survey}
brandColor={brandColor}
activeQuestionId={activeQuestionId || undefined}
formbricksSignature={product.formbricksSignature}
isBrandingEnabled={product.linkSurveyBranding}
onActiveQuestionChange={setActiveQuestionId}
/>
</div>
@@ -274,7 +274,7 @@ export default function PreviewSurvey({
survey={survey}
brandColor={brandColor}
activeQuestionId={activeQuestionId || undefined}
formbricksSignature={product.formbricksSignature}
isBrandingEnabled={product.linkSurveyBranding}
onActiveQuestionChange={setActiveQuestionId}
isRedirectDisabled={true}
/>
@@ -287,7 +287,7 @@ export default function PreviewSurvey({
survey={survey}
brandColor={brandColor}
activeQuestionId={activeQuestionId || undefined}
formbricksSignature={product.formbricksSignature}
isBrandingEnabled={product.linkSurveyBranding}
onActiveQuestionChange={setActiveQuestionId}
isRedirectDisabled={true}
/>

View File

@@ -1,21 +1,21 @@
import { UsageAttributesUpdater } from "@/app/(app)/components/FormbricksClient";
import SurveyDropDownMenu from "@/app/(app)/environments/[environmentId]/surveys/components/SurveyDropDownMenu";
import SurveyStarter from "@/app/(app)/environments/[environmentId]/surveys/components/SurveyStarter";
import { SurveyStatusIndicator } from "@formbricks/ui/SurveyStatusIndicator";
import { generateSurveySingleUseId } from "@/app/lib/singleUseSurveys";
import { authOptions } from "@formbricks/lib/authOptions";
import { WEBAPP_URL } from "@formbricks/lib/constants";
import { getEnvironment, getEnvironments } from "@formbricks/lib/environment/service";
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
import { getSurveys } from "@formbricks/lib/survey/service";
import type { TEnvironment } from "@formbricks/types/environment";
import { Badge } from "@formbricks/ui/Badge";
import { ComputerDesktopIcon, LinkIcon, PlusIcon } from "@heroicons/react/24/solid";
import Link from "next/link";
import { generateSurveySingleUseId } from "@/app/lib/singleUseSurveys";
import { getServerSession } from "next-auth";
import { authOptions } from "@formbricks/lib/authOptions";
import { getTeamByEnvironmentId } from "@formbricks/lib/team/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";
import { ComputerDesktopIcon, LinkIcon, PlusIcon } from "@heroicons/react/24/solid";
import { getServerSession } from "next-auth";
import Link from "next/link";
export default async function SurveysList({ environmentId }: { environmentId: string }) {
const session = await getServerSession(authOptions);

View File

@@ -15,7 +15,7 @@ import { SplitIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { createSurveyAction } from "../actions";
import { customSurvey, templates } from "./templates";
import { customSurvey, templates, testTemplate } from "./templates";
type TemplateList = {
environmentId: string;
@@ -147,7 +147,10 @@ export default function TemplateList({
</div>
)}
</button>
{filteredTemplates.map((template: TTemplate) => (
{(process.env.NODE_ENV === "development"
? [...filteredTemplates, testTemplate]
: filteredTemplates
).map((template: TTemplate) => (
<div
onClick={() => {
const newTemplate = replacePresetPlaceholders(template, product);

View File

@@ -18,7 +18,309 @@ const welcomeCardDefault: TSurveyWelcomeCard = {
enabled: false,
headline: "Welcome!",
html: "Thanks for providing your feedback - let's go!",
timeToFinish: false,
timeToFinish: true,
};
export const testTemplate: TTemplate = {
name: "Test template",
description: "Test template consisting of all questions",
preset: {
name: "Test template",
questions: [
{
id: createId(),
type: TSurveyQuestionType.OpenText,
headline: "This is an open text question",
subheader: "Please enter some text:",
required: true,
inputType: "text",
},
{
id: createId(),
type: TSurveyQuestionType.OpenText,
headline: "This is an open text question",
subheader: "Please enter some text:",
required: false,
inputType: "text",
},
{
id: createId(),
type: TSurveyQuestionType.OpenText,
headline: "This is an open text question",
subheader: "Please enter an email",
required: true,
inputType: "email",
},
{
id: createId(),
type: TSurveyQuestionType.OpenText,
headline: "This is an open text question",
subheader: "Please enter an email",
required: false,
inputType: "email",
},
{
id: createId(),
type: TSurveyQuestionType.OpenText,
headline: "This is an open text question",
subheader: "Please enter a number",
required: true,
inputType: "number",
},
{
id: createId(),
type: TSurveyQuestionType.OpenText,
headline: "This is an open text question",
subheader: "Please enter a number",
required: false,
inputType: "number",
},
{
id: createId(),
type: TSurveyQuestionType.OpenText,
headline: "This is an open text question",
subheader: "Please enter a phone number",
required: true,
inputType: "phone",
},
{
id: createId(),
type: TSurveyQuestionType.OpenText,
headline: "This is an open text question",
subheader: "Please enter a phone number",
required: false,
inputType: "phone",
},
{
id: createId(),
type: TSurveyQuestionType.OpenText,
headline: "This is an open text question",
subheader: "Please enter a url",
required: true,
inputType: "url",
},
{
id: createId(),
type: TSurveyQuestionType.OpenText,
headline: "This is an open text question",
subheader: "Please enter a url",
required: false,
inputType: "url",
},
{
id: createId(),
type: TSurveyQuestionType.MultipleChoiceSingle,
headline: "This ia a Multiple choice Single question",
subheader: "Please select one of the following",
required: true,
shuffleOption: "none",
choices: [
{
id: createId(),
label: "Option1",
},
{
id: createId(),
label: "Option2",
},
],
},
{
id: createId(),
type: TSurveyQuestionType.MultipleChoiceSingle,
headline: "This ia a Multiple choice Single question",
subheader: "Please select one of the following",
required: false,
shuffleOption: "none",
choices: [
{
id: createId(),
label: "Option 1",
},
{
id: createId(),
label: "Option 2",
},
],
},
{
id: createId(),
type: TSurveyQuestionType.MultipleChoiceMulti,
headline: "This ia a Multiple choice Multiple question",
subheader: "Please select some from the following",
required: true,
shuffleOption: "none",
choices: [
{
id: createId(),
label: "Option1",
},
{
id: createId(),
label: "Option2",
},
],
},
{
id: createId(),
type: TSurveyQuestionType.MultipleChoiceMulti,
headline: "This ia a Multiple choice Multiple question",
subheader: "Please select some from the following",
required: false,
shuffleOption: "none",
choices: [
{
id: createId(),
label: "Option1",
},
{
id: createId(),
label: "Option2",
},
],
},
{
id: createId(),
type: TSurveyQuestionType.Rating,
headline: "This is a rating question",
required: true,
lowerLabel: "Low",
upperLabel: "High",
range: 5,
scale: "number",
},
{
id: createId(),
type: TSurveyQuestionType.Rating,
headline: "This is a rating question",
required: false,
lowerLabel: "Low",
upperLabel: "High",
range: 5,
scale: "number",
},
{
id: createId(),
type: TSurveyQuestionType.Rating,
headline: "This is a rating question",
required: true,
lowerLabel: "Low",
upperLabel: "High",
range: 5,
scale: "smiley",
},
{
id: createId(),
type: TSurveyQuestionType.Rating,
headline: "This is a rating question",
required: false,
lowerLabel: "Low",
upperLabel: "High",
range: 5,
scale: "smiley",
},
{
id: createId(),
type: TSurveyQuestionType.Rating,
headline: "This is a rating question",
required: true,
lowerLabel: "Low",
upperLabel: "High",
range: 5,
scale: "star",
},
{
id: createId(),
type: TSurveyQuestionType.Rating,
headline: "This is a rating question",
required: false,
lowerLabel: "Low",
upperLabel: "High",
range: 5,
scale: "star",
},
{
id: createId(),
type: TSurveyQuestionType.CTA,
headline: "This is a CTA question",
html: "This is a test CTA",
buttonLabel: "Click",
buttonUrl: "https://formbricks.com",
buttonExternal: true,
required: true,
dismissButtonLabel: "Maybe later",
},
{
id: createId(),
type: TSurveyQuestionType.CTA,
headline: "This is a CTA question",
html: "This is a test CTA",
buttonLabel: "Click",
buttonUrl: "https://formbricks.com",
buttonExternal: true,
required: false,
dismissButtonLabel: "Maybe later",
},
{
id: createId(),
type: TSurveyQuestionType.PictureSelection,
headline: "This is a Picture select",
allowMulti: true,
required: true,
choices: [
{
id: createId(),
imageUrl: "https://formbricks-cdn.s3.eu-central-1.amazonaws.com/puppy-1-small.jpg",
},
{
id: createId(),
imageUrl: "https://formbricks-cdn.s3.eu-central-1.amazonaws.com/puppy-2-small.jpg",
},
],
},
{
id: createId(),
type: TSurveyQuestionType.PictureSelection,
headline: "This is a Picture select",
allowMulti: true,
required: false,
choices: [
{
id: createId(),
imageUrl: "https://formbricks-cdn.s3.eu-central-1.amazonaws.com/puppy-1-small.jpg",
},
{
id: createId(),
imageUrl: "https://formbricks-cdn.s3.eu-central-1.amazonaws.com/puppy-2-small.jpg",
},
],
},
{
id: createId(),
type: TSurveyQuestionType.Consent,
headline: "This is a Consent question",
required: true,
label: "I agree to the terms and conditions",
dismissButtonLabel: "Skip",
},
{
id: createId(),
type: TSurveyQuestionType.Consent,
headline: "This is a Consent question",
required: false,
label: "I agree to the terms and conditions",
dismissButtonLabel: "Skip",
},
],
thankYouCard: thankYouCardDefault,
welcomeCard: {
enabled: false,
timeToFinish: false,
},
hiddenFields: {
enabled: false,
},
},
};
export const templates: TTemplate[] = [
@@ -104,7 +406,7 @@ export const templates: TTemplate[] = [
{
id: createId(),
type: TSurveyQuestionType.OpenText,
headline: "What is the main benefit your receive from {{productName}}?",
headline: "What is the main benefit you receive from {{productName}}?",
required: true,
inputType: "text",
},

View File

@@ -3,6 +3,7 @@
import { Button } from "@formbricks/ui/Button";
import type { Session } from "next-auth";
import Link from "next/link";
import { useEffect, useRef } from "react";
type Greeting = {
next: () => void;
@@ -13,6 +14,27 @@ type Greeting = {
const Greeting: React.FC<Greeting> = ({ next, skip, name, session }) => {
const legacyUser = !session ? false : new Date(session?.user?.createdAt) < new Date("2023-05-03T00:00:00"); // if user is created before onboarding deployment
const buttonRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Enter") {
event.preventDefault();
next();
}
};
const button = buttonRef.current;
if (button) {
button.focus();
button.addEventListener("keydown", handleKeyDown);
}
return () => {
if (button) {
button.removeEventListener("keydown", handleKeyDown);
}
};
}, []);
return (
<div className="flex h-full w-full max-w-xl flex-col justify-around gap-8 px-8">
@@ -30,7 +52,7 @@ const Greeting: React.FC<Greeting> = ({ next, skip, name, session }) => {
<Button size="lg" variant="minimal" onClick={skip}>
I&apos;ll do it later
</Button>
<Button size="lg" variant="darkCTA" onClick={next}>
<Button size="lg" variant="darkCTA" onClick={next} ref={buttonRef} tabIndex={0}>
Begin (1 min)
</Button>
</div>

View File

@@ -1,14 +1,15 @@
"use client";
import { updateProfileAction } from "@/app/(app)/onboarding/actions";
import { env } from "@/env.mjs";
import { env } from "@formbricks/lib/env.mjs";
import { formbricksEnabled, updateResponse } from "@/app/lib/formbricks";
import { cn } from "@formbricks/lib/cn";
import { TProfileObjective } from "@formbricks/types/profile";
import { TProfile } from "@formbricks/types/profile";
import { Button } from "@formbricks/ui/Button";
import { useState } from "react";
import { useEffect, useRef, useState } from "react";
import { toast } from "react-hot-toast";
import { handleTabNavigation } from "../utils";
type ObjectiveProps = {
next: () => void;
@@ -35,18 +36,26 @@ const Objective: React.FC<ObjectiveProps> = ({ next, skip, formbricksResponseId,
const [selectedChoice, setSelectedChoice] = useState<string | null>(null);
const [isProfileUpdating, setIsProfileUpdating] = useState(false);
const fieldsetRef = useRef<HTMLFieldSetElement>(null);
useEffect(() => {
const onKeyDown = handleTabNavigation(fieldsetRef, setSelectedChoice);
window.addEventListener("keydown", onKeyDown);
return () => {
window.removeEventListener("keydown", onKeyDown);
};
}, [fieldsetRef, setSelectedChoice]);
const handleNextClick = async () => {
if (selectedChoice) {
const selectedObjective = objectives.find((objective) => objective.label === selectedChoice);
if (selectedObjective) {
try {
setIsProfileUpdating(true);
const updatedProfile = {
...profile,
await updateProfileAction({
objective: selectedObjective.id,
name: profile.name ?? undefined,
};
await updateProfileAction(updatedProfile);
});
setIsProfileUpdating(false);
} catch (e) {
setIsProfileUpdating(false);
@@ -73,14 +82,14 @@ const Objective: React.FC<ObjectiveProps> = ({ next, skip, formbricksResponseId,
return (
<div className="flex w-full max-w-xl flex-col gap-8 px-8">
<div className="px-4">
<label className="mb-1.5 block text-base font-semibold leading-6 text-slate-900">
<label htmlFor="choices" className="mb-1.5 block text-base font-semibold leading-6 text-slate-900">
What do you want to achieve?
</label>
<label className="block text-sm font-normal leading-6 text-slate-500">
We have 85+ templates, help us select the best for your need.
</label>
<div className="mt-4">
<fieldset>
<fieldset id="choices" aria-label="What do you want to achieve?" ref={fieldsetRef}>
<legend className="sr-only">Choices</legend>
<div className=" relative space-y-2 rounded-md">
{objectives.map((choice) => (
@@ -103,6 +112,11 @@ const Objective: React.FC<ObjectiveProps> = ({ next, skip, formbricksResponseId,
onChange={(e) => {
setSelectedChoice(e.currentTarget.value);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
handleNextClick();
}
}}
/>
<span id={`${choice.id}-label`} className="ml-3 font-medium">
{choice.label}

View File

@@ -88,12 +88,7 @@ export default function Onboarding({ session, environmentId, profile, product }:
<Greeting next={next} skip={doLater} name={profile.name ? profile.name : ""} session={session} />
)}
{currentStep === 2 && (
<Role
next={next}
skip={skipStep}
setFormbricksResponseId={setFormbricksResponseId}
profile={profile}
/>
<Role next={next} skip={skipStep} setFormbricksResponseId={setFormbricksResponseId} />
)}
{currentStep === 3 && (
<Objective

View File

@@ -10,6 +10,7 @@ import { Label } from "@formbricks/ui/Label";
import { ErrorComponent } from "@formbricks/ui/ErrorComponent";
import { useEffect, useState } from "react";
import { toast } from "react-hot-toast";
import { isLight } from "@/app/lib/utils";
type Product = {
done: () => void;
@@ -73,6 +74,10 @@ const Product: React.FC<Product> = ({ done, isLoading, environmentId, product })
if (!product) {
return <ErrorComponent />;
}
const buttonStyle = {
backgroundColor: color,
color: isLight(color) ? "black" : "white",
};
return (
<div className="flex w-full max-w-xl flex-col gap-8 px-8">
@@ -96,6 +101,7 @@ const Product: React.FC<Product> = ({ done, isLoading, environmentId, product })
placeholder="e.g. Formbricks"
value={name}
onChange={handleNameChange}
aria-label="Your product name"
/>
</div>
</div>
@@ -139,7 +145,7 @@ const Product: React.FC<Product> = ({ done, isLoading, environmentId, product })
</fieldset>
</div>
<div className="mt-4 flex w-full justify-end">
<Button className="pointer-events-none" style={{ backgroundColor: color }}>
<Button className="pointer-events-none" style={buttonStyle}>
Next
</Button>
</div>

View File

@@ -1,19 +1,18 @@
"use client";
import { cn } from "@formbricks/lib/cn";
import { updateProfileAction } from "@/app/(app)/onboarding/actions";
import { env } from "@/env.mjs";
import { env } from "@formbricks/lib/env.mjs";
import { createResponse, formbricksEnabled } from "@/app/lib/formbricks";
import { TProfile } from "@formbricks/types/profile";
import { cn } from "@formbricks/lib/cn";
import { Button } from "@formbricks/ui/Button";
import { useState } from "react";
import { useEffect, useRef, useState } from "react";
import { toast } from "react-hot-toast";
import { handleTabNavigation } from "../utils";
type RoleProps = {
next: () => void;
skip: () => void;
setFormbricksResponseId: (id: string) => void;
profile: TProfile;
};
type RoleChoice = {
@@ -21,9 +20,18 @@ type RoleChoice = {
id: "project_manager" | "engineer" | "founder" | "marketing_specialist" | "other";
};
const Role: React.FC<RoleProps> = ({ next, skip, setFormbricksResponseId, profile }) => {
const Role: React.FC<RoleProps> = ({ next, skip, setFormbricksResponseId }) => {
const [selectedChoice, setSelectedChoice] = useState<string | null>(null);
const [isUpdating, setIsUpdating] = useState(false);
const fieldsetRef = useRef<HTMLFieldSetElement>(null);
useEffect(() => {
const onKeyDown = handleTabNavigation(fieldsetRef, setSelectedChoice);
window.addEventListener("keydown", onKeyDown);
return () => {
window.removeEventListener("keydown", onKeyDown);
};
}, [fieldsetRef, setSelectedChoice]);
const roles: Array<RoleChoice> = [
{ label: "Project Manager", id: "project_manager" },
@@ -39,8 +47,7 @@ const Role: React.FC<RoleProps> = ({ next, skip, setFormbricksResponseId, profil
if (selectedRole) {
try {
setIsUpdating(true);
const updatedProfile = { ...profile, role: selectedRole.id };
await updateProfileAction(updatedProfile);
await updateProfileAction({ role: selectedRole.id });
setIsUpdating(false);
} catch (e) {
setIsUpdating(false);
@@ -66,19 +73,20 @@ const Role: React.FC<RoleProps> = ({ next, skip, setFormbricksResponseId, profil
return (
<div className="flex w-full max-w-xl flex-col gap-8 px-8">
<div className="px-4">
<label className="mb-1.5 block text-base font-semibold leading-6 text-slate-900">
<label htmlFor="choices" className="mb-1.5 block text-base font-semibold leading-6 text-slate-900">
What is your role?
</label>
<label className="block text-sm font-normal leading-6 text-slate-500">
Make your Formbricks experience more personalised.
</label>
<div className="mt-4">
<fieldset>
<fieldset id="choices" aria-label="What is your role?" ref={fieldsetRef}>
<legend className="sr-only">Choices</legend>
<div className=" relative space-y-2 rounded-md">
{roles.map((choice) => (
<label
key={choice.id}
htmlFor={choice.id}
className={cn(
selectedChoice === choice.label
? "z-10 border-slate-400 bg-slate-100"
@@ -90,12 +98,18 @@ const Role: React.FC<RoleProps> = ({ next, skip, setFormbricksResponseId, profil
type="radio"
id={choice.id}
value={choice.label}
name="role"
checked={choice.label === selectedChoice}
className="checked:text-brand-dark focus:text-brand-dark h-4 w-4 border border-gray-300 focus:ring-0 focus:ring-offset-0"
aria-labelledby={`${choice.id}-label`}
onChange={(e) => {
setSelectedChoice(e.currentTarget.value);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
handleNextClick();
}
}}
/>
<span id={`${choice.id}-label`} className="ml-3 font-medium">
{choice.label}

View File

@@ -7,11 +7,12 @@ import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
import { getProfile } from "@formbricks/lib/profile/service";
import { getServerSession } from "next-auth";
import Onboarding from "./components/Onboarding";
import { redirect } from "next/navigation";
export default async function OnboardingPage() {
const session = await getServerSession(authOptions);
if (!session) {
throw new Error("No session found");
redirect("/auth/login");
}
const userId = session?.user.id;
const environment = await getFirstEnvironmentByUserId(userId);

View File

@@ -0,0 +1,34 @@
// util.js
export const handleTabNavigation = (fieldsetRef, setSelectedChoice) => (event) => {
if (event.key !== "Tab") {
return;
}
event.preventDefault();
const radioButtons = fieldsetRef.current?.querySelectorAll('input[type="radio"]');
if (!radioButtons || radioButtons.length === 0) {
return;
}
const focusedRadioButton = fieldsetRef.current?.querySelector(
'input[type="radio"]:focus'
) as HTMLInputElement;
if (!focusedRadioButton) {
// If no radio button is focused, then it will focus on the first one by default
const firstRadioButton = radioButtons[0] as HTMLInputElement;
firstRadioButton.focus();
setSelectedChoice(firstRadioButton.value);
return;
}
const focusedIndex = Array.from(radioButtons).indexOf(focusedRadioButton);
const lastIndex = radioButtons.length - 1;
// Calculating the next index, considering wrapping from the last to the first element
const nextIndex = focusedIndex === lastIndex ? 0 : focusedIndex + 1;
const nextRadioButton = radioButtons[nextIndex] as HTMLInputElement;
nextRadioButton.focus();
setSelectedChoice(nextRadioButton.value);
};

View File

@@ -1,8 +1,9 @@
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
import { authOptions } from "@formbricks/lib/authOptions";
export default async function AuthLayout({ children }: { children: React.ReactNode }) {
const session = await getServerSession();
const session = await getServerSession(authOptions);
if (session) {
redirect(`/`);
}

View File

@@ -10,7 +10,7 @@ import {
UsedContent,
RightAccountContent,
} from "./components/InviteContentComponents";
import { env } from "@/env.mjs";
import { env } from "@formbricks/lib/env.mjs";
export default async function JoinTeam({ searchParams }) {
const currentUser = await getServerSession(authOptions);

View File

@@ -51,7 +51,7 @@ async function reportTeamUsage(team: TTeam) {
}
}
export async function GET(): Promise<NextResponse> {
export async function POST(): Promise<NextResponse> {
const headersList = headers();
const apiKey = headersList.get("x-api-key");

View File

@@ -1,10 +1,11 @@
import { prisma } from "@formbricks/database";
import { responses } from "@/app/lib/api/response";
import {
GOOGLE_SHEETS_CLIENT_ID,
WEBAPP_URL,
GOOGLE_SHEETS_CLIENT_SECRET,
GOOGLE_SHEETS_REDIRECT_URL,
} from "@formbricks/lib/constants";
import { createOrUpdateIntegration } from "@formbricks/lib/integration/service";
import { google } from "googleapis";
import { NextRequest, NextResponse } from "next/server";
@@ -15,19 +16,19 @@ export async function GET(req: NextRequest) {
const code = queryParams.get("code");
if (!environmentId) {
return NextResponse.json({ error: "Invalid environmentId" });
return responses.badRequestResponse("Invalid environmentId");
}
if (code && typeof code !== "string") {
return NextResponse.json({ message: "`code` must be a string" }, { status: 400 });
return responses.badRequestResponse("`code` must be a string");
}
const client_id = GOOGLE_SHEETS_CLIENT_ID;
const client_secret = GOOGLE_SHEETS_CLIENT_SECRET;
const redirect_uri = GOOGLE_SHEETS_REDIRECT_URL;
if (!client_id) return NextResponse.json({ Error: "Google client id is missing" }, { status: 400 });
if (!client_secret) return NextResponse.json({ Error: "Google client secret is missing" }, { status: 400 });
if (!redirect_uri) return NextResponse.json({ Error: "Google redirect url is missing" }, { status: 400 });
if (!client_id) return responses.internalServerErrorResponse("Google client id is missing");
if (!client_secret) return responses.internalServerErrorResponse("Google client secret is missing");
if (!redirect_uri) return responses.internalServerErrorResponse("Google redirect url is missing");
const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uri);
let key;
@@ -61,22 +62,7 @@ export async function GET(req: NextRequest) {
},
};
const result = await prisma.integration.upsert({
where: {
type_environmentId: {
environmentId,
type: "googleSheets",
},
},
update: {
...googleSheetIntegration,
environment: { connect: { id: environmentId } },
},
create: {
...googleSheetIntegration,
environment: { connect: { id: environmentId } },
},
});
const result = await createOrUpdateIntegration(environmentId, googleSheetIntegration);
if (result) {
return NextResponse.redirect(`${WEBAPP_URL}/environments/${environmentId}/integrations/google-sheets`);

View File

@@ -1,11 +1,12 @@
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
import { responses } from "@/app/lib/api/response";
import {
GOOGLE_SHEETS_CLIENT_ID,
GOOGLE_SHEETS_CLIENT_SECRET,
GOOGLE_SHEETS_REDIRECT_URL,
} from "@formbricks/lib/constants";
import { google } from "googleapis";
import { NextRequest, NextResponse } from "next/server";
import { NextRequest } from "next/server";
import { authOptions } from "@formbricks/lib/authOptions";
import { getServerSession } from "next-auth";
@@ -20,24 +21,24 @@ export async function GET(req: NextRequest) {
const session = await getServerSession(authOptions);
if (!environmentId) {
return NextResponse.json({ Error: "environmentId is missing" }, { status: 400 });
return responses.badRequestResponse("environmentId is missing");
}
if (!session) {
return NextResponse.json({ Error: "Invalid session" }, { status: 400 });
return responses.notAuthenticatedResponse();
}
const canUserAccessEnvironment = await hasUserEnvironmentAccess(session?.user.id, environmentId);
if (!canUserAccessEnvironment) {
return NextResponse.json({ Error: "You dont have access to environment" }, { status: 401 });
return responses.unauthorizedResponse();
}
const client_id = GOOGLE_SHEETS_CLIENT_ID;
const client_secret = GOOGLE_SHEETS_CLIENT_SECRET;
const redirect_uri = GOOGLE_SHEETS_REDIRECT_URL;
if (!client_id) return NextResponse.json({ Error: "Google client id is missing" }, { status: 400 });
if (!client_secret) return NextResponse.json({ Error: "Google client secret is missing" }, { status: 400 });
if (!redirect_uri) return NextResponse.json({ Error: "Google redirect url is missing" }, { status: 400 });
if (!client_id) return responses.internalServerErrorResponse("Google client id is missing");
if (!client_secret) return responses.internalServerErrorResponse("Google client secret is missing");
if (!redirect_uri) return responses.internalServerErrorResponse("Google redirect url is missing");
const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uri);
const authUrl = oAuth2Client.generateAuthUrl({
@@ -47,5 +48,5 @@ export async function GET(req: NextRequest) {
state: environmentId!,
});
return NextResponse.json({ authUrl }, { status: 200 });
return responses.successResponse({ authUrl });
}

View File

@@ -0,0 +1,95 @@
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { getActionClasses } from "@formbricks/lib/actionClass/service";
import { createAttributeClass, getAttributeClassByName } from "@formbricks/lib/attributeClass/service";
import { personCache } from "@formbricks/lib/person/cache";
import { getPerson, updatePersonAttribute } from "@formbricks/lib/person/service";
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
import { surveyCache } from "@formbricks/lib/survey/cache";
import { getSyncSurveys } from "@formbricks/lib/survey/service";
import { TJsState, ZJsPeopleAttributeInput } from "@formbricks/types/js";
import { NextResponse } from "next/server";
interface Context {
params: {
userId: string;
environmentId: string;
};
}
export async function OPTIONS(): Promise<NextResponse> {
return responses.successResponse({}, true);
}
export async function POST(req: Request, context: Context): Promise<NextResponse> {
try {
const { userId, environmentId } = context.params;
const personId = userId; // legacy workaround for formbricks-js 1.2.0 & 1.2.1
const jsonInput = await req.json();
// validate using zod
const inputValidation = ZJsPeopleAttributeInput.safeParse(jsonInput);
if (!inputValidation.success) {
return responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error),
true
);
}
const { key, value } = inputValidation.data;
const person = await getPerson(personId);
if (!person) {
return responses.notFoundResponse("Person", personId, true);
}
let attributeClass = await getAttributeClassByName(environmentId, key);
// create new attribute class if not found
if (attributeClass === null) {
attributeClass = await createAttributeClass(environmentId, key, "code");
}
if (!attributeClass) {
return responses.internalServerErrorResponse("Unable to create attribute class", true);
}
// upsert attribute (update or create)
await updatePersonAttribute(personId, attributeClass.id, value);
personCache.revalidate({
id: personId,
environmentId,
});
surveyCache.revalidate({
environmentId,
});
const [surveys, noCodeActionClasses, product] = await Promise.all([
getSyncSurveys(environmentId, person),
getActionClasses(environmentId),
getProductByEnvironmentId(environmentId),
]);
if (!product) {
throw new Error("Product not found");
}
// return state
const state: TJsState = {
person,
surveys,
noCodeActionClasses: noCodeActionClasses.filter((actionClass) => actionClass.type === "noCode"),
product,
};
return responses.successResponse({ ...state }, true);
} catch (error) {
console.error(error);
return responses.internalServerErrorResponse(`Unable to complete request: ${error.message}`, true);
}
}

View File

@@ -1,5 +1,5 @@
import { responses } from "@/app/lib/api/response";
import { markDisplayResponded } from "@formbricks/lib/display/service";
import { markDisplayRespondedLegacy } from "@formbricks/lib/display/service";
import { NextResponse } from "next/server";
export async function OPTIONS(): Promise<NextResponse> {
@@ -14,7 +14,7 @@ export async function POST(_: Request, { params }: { params: { displayId: string
}
try {
const display = await markDisplayResponded(displayId);
const display = await markDisplayRespondedLegacy(displayId);
return responses.successResponse(
{
...display,

View File

@@ -1,8 +1,8 @@
import { responses } from "@/app/lib/api/response";
import { updateDisplay } from "@formbricks/lib/display/service";
import { TDisplayCreateInput, ZDisplayUpdateInput } from "@formbricks/types/displays";
import { NextResponse } from "next/server";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { updateDisplayLegacy } from "@formbricks/lib/display/service";
import { ZDisplayLegacyUpdateInput } from "@formbricks/types/displays";
import { NextResponse } from "next/server";
export async function OPTIONS(): Promise<NextResponse> {
return responses.successResponse({}, true);
@@ -16,8 +16,8 @@ export async function PUT(
if (!displayId) {
return responses.badRequestResponse("Missing displayId", undefined, true);
}
const displayInput: TDisplayCreateInput = await request.json();
const inputValidation = ZDisplayUpdateInput.safeParse(displayInput);
const displayInput = await request.json();
const inputValidation = ZDisplayLegacyUpdateInput.safeParse(displayInput);
if (!inputValidation.success) {
return responses.badRequestResponse(
@@ -27,7 +27,7 @@ export async function PUT(
);
}
try {
const display = await updateDisplay(displayId, inputValidation.data);
const display = await updateDisplayLegacy(displayId, inputValidation.data);
return responses.successResponse(display, true);
} catch (error) {
console.error(error);

View File

@@ -1,11 +1,11 @@
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { InvalidInputError } from "@formbricks/types/errors";
import { createDisplayLegacy } from "@formbricks/lib/display/service";
import { capturePosthogEvent } from "@formbricks/lib/posthogServer";
import { createDisplay } from "@formbricks/lib/display/service";
import { getSurvey } from "@formbricks/lib/survey/service";
import { getTeamDetails } from "@formbricks/lib/teamDetail/service";
import { TDisplay, ZDisplayCreateInput } from "@formbricks/types/displays";
import { TDisplay, ZDisplayLegacyCreateInput } from "@formbricks/types/displays";
import { InvalidInputError } from "@formbricks/types/errors";
import { NextResponse } from "next/server";
export async function OPTIONS(): Promise<NextResponse> {
@@ -13,8 +13,11 @@ export async function OPTIONS(): Promise<NextResponse> {
}
export async function POST(request: Request): Promise<NextResponse> {
const jsonInput: unknown = await request.json();
const inputValidation = ZDisplayCreateInput.safeParse(jsonInput);
const jsonInput = await request.json();
if (jsonInput.personId === "legacy") {
delete jsonInput.personId;
}
const inputValidation = ZDisplayLegacyCreateInput.safeParse(jsonInput);
if (!inputValidation.success) {
return responses.badRequestResponse(
@@ -24,12 +27,14 @@ export async function POST(request: Request): Promise<NextResponse> {
);
}
const displayInput = inputValidation.data;
const { surveyId, responseId } = inputValidation.data;
let { personId } = inputValidation.data;
// find environmentId from surveyId
let survey;
try {
survey = await getSurvey(displayInput.surveyId);
survey = await getSurvey(surveyId);
} catch (error) {
if (error instanceof InvalidInputError) {
return responses.badRequestResponse(error.message);
@@ -45,7 +50,11 @@ export async function POST(request: Request): Promise<NextResponse> {
// create display
let display: TDisplay;
try {
display = await createDisplay(displayInput);
display = await createDisplayLegacy({
surveyId,
personId,
responseId,
});
} catch (error) {
if (error instanceof InvalidInputError) {
return responses.badRequestResponse(error.message);
@@ -57,7 +66,7 @@ export async function POST(request: Request): Promise<NextResponse> {
if (teamDetails?.teamOwnerId) {
await capturePosthogEvent(teamDetails.teamOwnerId, "display created", teamDetails.teamId, {
surveyId: displayInput.surveyId,
surveyId,
});
} else {
console.warn("Posthog capture not possible. No team owner found");

View File

@@ -0,0 +1,94 @@
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { getActionClasses } from "@formbricks/lib/actionClass/service";
import { createAttributeClass, getAttributeClassByName } from "@formbricks/lib/attributeClass/service";
import { personCache } from "@formbricks/lib/person/cache";
import { getPerson, updatePersonAttribute } from "@formbricks/lib/person/service";
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
import { surveyCache } from "@formbricks/lib/survey/cache";
import { getSyncSurveys } from "@formbricks/lib/survey/service";
import { TJsState, ZJsPeopleAttributeInput } from "@formbricks/types/js";
import { NextResponse } from "next/server";
interface Context {
params: {
personId: string;
environmentId: string;
};
}
export async function OPTIONS(): Promise<NextResponse> {
return responses.successResponse({}, true);
}
export async function POST(req: Request, context: Context): Promise<NextResponse> {
try {
const { personId, environmentId } = context.params;
const jsonInput = await req.json();
// validate using zod
const inputValidation = ZJsPeopleAttributeInput.safeParse(jsonInput);
if (!inputValidation.success) {
return responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error),
true
);
}
const { key, value } = inputValidation.data;
const person = await getPerson(personId);
if (!person) {
return responses.notFoundResponse("Person", personId, true);
}
let attributeClass = await getAttributeClassByName(environmentId, key);
// create new attribute class if not found
if (attributeClass === null) {
attributeClass = await createAttributeClass(environmentId, key, "code");
}
if (!attributeClass) {
return responses.internalServerErrorResponse("Unable to create attribute class", true);
}
// upsert attribute (update or create)
await updatePersonAttribute(personId, attributeClass.id, value);
personCache.revalidate({
id: personId,
environmentId,
});
surveyCache.revalidate({
environmentId,
});
const [surveys, noCodeActionClasses, product] = await Promise.all([
getSyncSurveys(environmentId, person),
getActionClasses(environmentId),
getProductByEnvironmentId(environmentId),
]);
if (!product) {
throw new Error("Product not found");
}
// return state
const state: TJsState = {
person,
surveys,
noCodeActionClasses: noCodeActionClasses.filter((actionClass) => actionClass.type === "noCode"),
product,
};
return responses.successResponse({ ...state }, true);
} catch (error) {
console.error(error);
return responses.internalServerErrorResponse(`Unable to complete request: ${error.message}`, true);
}
}

View File

@@ -1,12 +1,13 @@
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { sendToPipeline } from "@/app/lib/pipelines";
import { InvalidInputError } from "@formbricks/types/errors";
import { capturePosthogEvent } from "@formbricks/lib/posthogServer";
import { createResponseLegacy } from "@formbricks/lib/response/service";
import { getSurvey } from "@formbricks/lib/survey/service";
import { createResponse } from "@formbricks/lib/response/service";
import { getTeamDetails } from "@formbricks/lib/teamDetail/service";
import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/responses";
import { InvalidInputError } from "@formbricks/types/errors";
import { TResponse, ZResponseLegacyInput } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys";
import { NextResponse } from "next/server";
import { UAParser } from "ua-parser-js";
@@ -15,9 +16,12 @@ export async function OPTIONS(): Promise<NextResponse> {
}
export async function POST(request: Request): Promise<NextResponse> {
const responseInput: TResponseInput = await request.json();
const responseInput = await request.json();
if (responseInput.personId === "legacy") {
responseInput.personId = null;
}
const agent = UAParser(request.headers.get("user-agent"));
const inputValidation = ZResponseInput.safeParse(responseInput);
const inputValidation = ZResponseLegacyInput.safeParse(responseInput);
if (!inputValidation.success) {
return responses.badRequestResponse(
@@ -27,10 +31,13 @@ export async function POST(request: Request): Promise<NextResponse> {
);
}
let survey;
let survey: TSurvey | null;
try {
survey = await getSurvey(responseInput.surveyId);
if (!survey) {
return responses.notFoundResponse("Survey", responseInput.surveyId);
}
} catch (error) {
if (error instanceof InvalidInputError) {
return responses.badRequestResponse(error.message);
@@ -54,7 +61,13 @@ export async function POST(request: Request): Promise<NextResponse> {
},
};
response = await createResponse({
// check if personId is anonymous
if (responseInput.personId === "anonymous") {
// remove this from the request
responseInput.personId = null;
}
response = await createResponseLegacy({
...responseInput,
meta,
});

View File

@@ -0,0 +1,10 @@
import { responses } from "@/app/lib/api/response";
import { NextResponse } from "next/server";
export async function OPTIONS(): Promise<NextResponse> {
return responses.successResponse({}, true);
}
export async function POST(): Promise<NextResponse> {
return responses.successResponse({}, true);
}

View File

@@ -1,11 +1,11 @@
import { getUpdatedState } from "@/app/api/v1/js/sync/lib/sync";
import { getUpdatedState } from "@/app/api/v1/(legacy)/js/sync/lib/sync";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { createAttributeClass, getAttributeClassByName } from "@formbricks/lib/attributeClass/service";
import { personCache } from "@formbricks/lib/person/cache";
import { getPerson, updatePersonAttribute } from "@formbricks/lib/person/service";
import { surveyCache } from "@formbricks/lib/survey/cache";
import { ZJsPeopleAttributeInput } from "@formbricks/types/js";
import { ZJsPeopleLegacyAttributeInput } from "@formbricks/types/js";
import { NextResponse } from "next/server";
export async function OPTIONS(): Promise<NextResponse> {
@@ -15,10 +15,15 @@ export async function OPTIONS(): Promise<NextResponse> {
export async function POST(req: Request, { params }): Promise<NextResponse> {
try {
const { personId } = params;
if (!personId || personId === "legacy") {
return responses.internalServerErrorResponse("setAttribute requires an identified user", true);
}
const jsonInput = await req.json();
// validate using zod
const inputValidation = ZJsPeopleAttributeInput.safeParse(jsonInput);
const inputValidation = ZJsPeopleLegacyAttributeInput.safeParse(jsonInput);
if (!inputValidation.success) {
return responses.badRequestResponse(
@@ -28,7 +33,7 @@ export async function POST(req: Request, { params }): Promise<NextResponse> {
);
}
const { environmentId, sessionId, key, value } = inputValidation.data;
const { environmentId, key, value } = inputValidation.data;
const existingPerson = await getPerson(personId);
@@ -59,7 +64,7 @@ export async function POST(req: Request, { params }): Promise<NextResponse> {
environmentId,
});
const state = await getUpdatedState(environmentId, personId, sessionId);
const state = await getUpdatedState(environmentId, personId);
return responses.successResponse({ ...state }, true);
} catch (error) {

View File

@@ -0,0 +1,40 @@
import { getUpdatedState } from "@/app/api/v1/(legacy)/js/sync/lib/sync";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { createPerson, getPersonByUserId } from "@formbricks/lib/person/service";
import { ZJsPeopleUserIdInput } from "@formbricks/types/js";
import { NextResponse } from "next/server";
export async function OPTIONS(): Promise<NextResponse> {
return responses.successResponse({}, true);
}
export async function POST(req: Request): Promise<NextResponse> {
try {
const jsonInput = await req.json();
// validate using zod
const inputValidation = ZJsPeopleUserIdInput.safeParse(jsonInput);
if (!inputValidation.success) {
return responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error),
true
);
}
const { environmentId, userId } = inputValidation.data;
let person = await getPersonByUserId(environmentId, userId);
if (!person) {
person = await createPerson(environmentId, userId);
}
const state = await getUpdatedState(environmentId, person.id);
return responses.successResponse({ ...state }, true);
} catch (error) {
console.error(error);
return responses.internalServerErrorResponse("Unable to handle the request: " + error.message, true);
}
}

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