mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-30 10:19:51 -06:00
Compare commits
17 Commits
ReviewBot/
...
shubham/e2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ac68c9110 | ||
|
|
e19a82e2e4 | ||
|
|
aeefb6cbd8 | ||
|
|
f5110fe9c1 | ||
|
|
8244a5fa48 | ||
|
|
59936e54a0 | ||
|
|
5468287f9b | ||
|
|
22e55677ae | ||
|
|
8d422eeda0 | ||
|
|
62dbd9e121 | ||
|
|
c950c96934 | ||
|
|
1fa12d473c | ||
|
|
b9def78d2e | ||
|
|
626356be55 | ||
|
|
85f5425d89 | ||
|
|
d2c703ef60 | ||
|
|
4e8e6390b1 |
56
.github/workflows/playwright.yml
vendored
56
.github/workflows/playwright.yml
vendored
@@ -1,27 +1,37 @@
|
||||
name: Playwright Tests
|
||||
name: E2E Tests
|
||||
on:
|
||||
push:
|
||||
branches: [ main, master ]
|
||||
pull_request:
|
||||
branches: [ main, master ]
|
||||
workflow_call:
|
||||
jobs:
|
||||
test:
|
||||
timeout-minutes: 60
|
||||
build:
|
||||
name: Run E2E Tests
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18
|
||||
- name: Install dependencies
|
||||
run: npm install -g pnpm && pnpm install
|
||||
- name: Install Playwright Browsers
|
||||
run: pnpm exec playwright install --with-deps
|
||||
- name: Run Playwright tests
|
||||
run: pnpm exec playwright test
|
||||
- uses: actions/upload-artifact@v3
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-report
|
||||
path: playwright-report/
|
||||
retention-days: 30
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Install Docker Compose
|
||||
run: sudo apt-get update && sudo apt-get install -y docker-compose
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install -g pnpm && pnpm install
|
||||
|
||||
- name: Build Formbricks Image & Run
|
||||
run: docker-compose up -d
|
||||
|
||||
- name: Install Playwright Browsers
|
||||
run: pnpm exec playwright install --with-deps
|
||||
|
||||
- name: Run Playwright tests
|
||||
run: pnpm test:e2e
|
||||
|
||||
- uses: actions/upload-artifact@v3
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-report
|
||||
path: playwright-report/
|
||||
retention-days: 30
|
||||
|
||||
7
.github/workflows/pr.yml
vendored
7
.github/workflows/pr.yml
vendored
@@ -27,8 +27,13 @@ jobs:
|
||||
uses: ./.github/workflows/build-web.yml
|
||||
secrets: inherit
|
||||
|
||||
e2e-test:
|
||||
name: Run E2E Tests
|
||||
uses: ./.github/workflows/playwright.yml
|
||||
secrets: inherit
|
||||
|
||||
required:
|
||||
needs: [lint, test, build]
|
||||
needs: [lint, test, build, e2e-test]
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
16
.github/workflows/release-docker-github.yml
vendored
16
.github/workflows/release-docker-github.yml
vendored
@@ -52,19 +52,22 @@ jobs:
|
||||
with:
|
||||
cosign-release: "v2.1.1"
|
||||
|
||||
# Add support for more platforms with QEMU (optional)
|
||||
# https://github.com/docker/setup-qemu-action
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
# Set up BuildKit Docker container builder to be able to build
|
||||
# multi-platform images and export cache
|
||||
# https://github.com/docker/setup-buildx-action
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0
|
||||
with:
|
||||
platforms: linux/amd64,linux/arm64
|
||||
uses: docker/setup-buildx-action@v3 # v3.0.0
|
||||
|
||||
# Login against a Docker registry except on PR
|
||||
# https://github.com/docker/login-action
|
||||
- name: Log into registry ${{ env.REGISTRY }}
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d # v3.0.0
|
||||
uses: docker/login-action@v3 # v3.0.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
@@ -74,7 +77,7 @@ jobs:
|
||||
# https://github.com/docker/metadata-action
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 # v5.0.0
|
||||
uses: docker/metadata-action@v5 # v5.0.0
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
|
||||
@@ -82,10 +85,11 @@ jobs:
|
||||
# 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
|
||||
uses: docker/build-push-action@v5 # v5.0.0
|
||||
with:
|
||||
context: .
|
||||
file: ./apps/web/Dockerfile
|
||||
# platforms: linux/amd64,linux/arm64
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -45,6 +45,8 @@ packages/database/zod
|
||||
.direnv
|
||||
|
||||
Zone.Identifier
|
||||
|
||||
# Playwright
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
/blob-report/
|
||||
|
||||
@@ -22,11 +22,13 @@ export default function AppPage({}) {
|
||||
if (process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID && process.env.NEXT_PUBLIC_FORMBRICKS_API_HOST) {
|
||||
const isUserId = window.location.href.includes("userId=true");
|
||||
const userId = isUserId ? "THIS-IS-A-VERY-LONG-USER-ID-FOR-TESTING" : undefined;
|
||||
const attributes = isUserId ? { "Init Attribute 1": "eight", "Init Attribute 2": "two" } : undefined;
|
||||
formbricks.init({
|
||||
environmentId: process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID,
|
||||
apiHost: process.env.NEXT_PUBLIC_FORMBRICKS_API_HOST,
|
||||
userId,
|
||||
debug: true,
|
||||
attributes,
|
||||
});
|
||||
window.formbricks = formbricks;
|
||||
}
|
||||
|
||||
@@ -16,7 +16,9 @@ Checkout the [API Key Setup](/docs/api/management/api-key-setup) - to generate,
|
||||
|
||||
The Public Client API is designed for the JavaScript SDK and does not require authentication. It's primarily used for creating persons, sessions, and responses within the Formbricks platform. This API is ideal for client-side interactions, as it doesn't expose sensitive information.
|
||||
|
||||
- [Actions API](/docs/api/client/actions) - Create actions for a person
|
||||
- [Displays API](/docs/api/client/displays) - Mark Survey as Displayed or Responded for a Person
|
||||
- [People API](/docs/api/client/people) - Create & update people (e.g. attributes)
|
||||
- [Responses API](/docs/api/client/responses) - Create & update responses for a survey
|
||||
|
||||
## Management API
|
||||
|
||||
@@ -10,9 +10,31 @@ export const metadata = {
|
||||
|
||||
One way to send attributes to Formbricks is in your code. In Formbricks, there are two special attributes for [user identification](/docs/attributes/identify-users)(user ID & email) and custom attributes. An example:
|
||||
|
||||
## Setting Custom User Attributes
|
||||
## Setting during Initialization
|
||||
|
||||
It's recommended to set custom user attributes directly during the initialization of Formbricks for better user identification.
|
||||
|
||||
<Col>
|
||||
<CodeGroup title="Set custom attributes during initialization">
|
||||
|
||||
```javascript
|
||||
formbricks.init({
|
||||
environmentId: "<environment-id>",
|
||||
apiHost: "<api-host>",
|
||||
userId: "<user_id>",
|
||||
attributes: {
|
||||
plan: "free",
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
</Col>
|
||||
|
||||
## Setting independently
|
||||
|
||||
You can use the setAttribute function to set any custom attribute for the user (e.g. name, plan, etc.) anywhere in the user journey. Formbricks maintains a state of the current user inside the browser and makes sure attributes aren't sent to the backend twice.
|
||||
|
||||
You can use the setAttribute function to set any custom attribute for the user (e.g. name, plan, etc.):
|
||||
<Col>
|
||||
<CodeGroup title="Setting Plan to Pro">
|
||||
|
||||
|
||||
@@ -29,6 +29,29 @@ formbricks.init({
|
||||
|
||||
</CodeGroup>
|
||||
</Col>
|
||||
|
||||
## Enhanced Initialization with User Attributes
|
||||
|
||||
In addition to setting the `userId`, Formbricks allows you to set user attributes right at the initialization. This ensures that your user data is seamlessly integrated from the start. Here's how you can include user attributes in the `init()` function:
|
||||
|
||||
<Col>
|
||||
<CodeGroup title="Enhanced Initialization with User Attributes">
|
||||
|
||||
```javascript
|
||||
formbricks.init({
|
||||
environmentId: "<environment-id>",
|
||||
apiHost: "<api-host>",
|
||||
userId: "<user_id>",
|
||||
attributes: {
|
||||
// your custom attributes
|
||||
Plan: "premium",
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
</Col>
|
||||
|
||||
## Setting User 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:
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { GettingStarted } from "@/components/docs/GettingStarted";
|
||||
import BestPractices from "@/components/docs/BestPractices";
|
||||
import { HeroPattern } from "@/components/docs/HeroPattern";
|
||||
import { Button } from "@/components/docs/Button";
|
||||
|
||||
@@ -9,10 +8,7 @@ export const metadata = {
|
||||
"Enhance your product with Formbricks – the leading open-source solution for in-product micro-surveys. Dive deep into user research, amplify product-market fit, and uncover the 'why' behind your analytics.",
|
||||
};
|
||||
|
||||
export const sections = [
|
||||
{ title: "Getting Started", id: "getting-started" },
|
||||
{ title: "Best Practices", id: "best-practices" },
|
||||
];
|
||||
export const sections = [];
|
||||
|
||||
<HeroPattern />
|
||||
|
||||
@@ -20,7 +16,7 @@ export const sections = [
|
||||
|
||||
Welcome to Formbricks, your go-to solution for in-product micro-surveys that will supercharge your product experience! 🚀 {{ className: 'lead' }}
|
||||
|
||||
<div className="mb-16 mt-6 flex gap-3">
|
||||
<div className="mb-16 mt-6 flex gap-3" id="why-formbricks">
|
||||
<Button href="/docs/getting-started/quickstart-in-app-survey" arrow="right" children="Quickstart" />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -39,17 +39,24 @@ Integrating Google OAuth with your Formbricks instance allows users to log in us
|
||||
- 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.
|
||||
|
||||
```
|
||||
<Col>
|
||||
<CodeGroup title="Configuration URLs">
|
||||
``` {{ title: 'Redirect & Origin URLs' }}
|
||||
Authorized JavaScript origins: {WEBAPP_URL}
|
||||
Authorized redirect URIs: {WEBAPP_URL}/api/auth/callback/google
|
||||
```
|
||||
</CodeGroup>
|
||||
</Col>
|
||||
|
||||
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):
|
||||
|
||||
```
|
||||
<Col>
|
||||
<CodeGroup title="Set env vars">
|
||||
|
||||
```sh {{ title: 'Shell commands' }}
|
||||
docker exec -it container_id /bin/bash
|
||||
export GOOGLE_AUTH_ENABLED=1
|
||||
export GOOGLE_CLIENT_ID=your-client-id-here
|
||||
@@ -57,12 +64,15 @@ export GOOGLE_CLIENT_SECRET=your-client-secret-here
|
||||
exit
|
||||
```
|
||||
|
||||
```
|
||||
```sh {{ title: 'env file' }}
|
||||
GOOGLE_AUTH_ENABLED=1
|
||||
GOOGLE_CLIENT_ID=your-client-id-here
|
||||
GOOGLE_CLIENT_SECRET=your-client-secret-here
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
</Col>
|
||||
|
||||
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:
|
||||
|
||||
@@ -27,7 +27,7 @@ export const Hero: React.FC = ({}) => {
|
||||
<ChevronRightIcon className="mb-1 ml-1 inline h-4 w-4 text-slate-300" />
|
||||
</a>
|
||||
<h1 className="mt-10 text-3xl font-bold tracking-tight text-slate-800 dark:text-slate-200 sm:text-4xl md:text-5xl">
|
||||
<span className="xl:inline">Prviacy-first Experience Management</span>
|
||||
<span className="xl:inline">Privacy-first Experience Management</span>
|
||||
</h1>
|
||||
|
||||
<p className="xs:max-w-none mx-auto mt-3 max-w-xs text-base text-slate-500 dark:text-slate-400 sm:text-lg md:mt-5 md:text-xl">
|
||||
|
||||
@@ -83,8 +83,8 @@ export default function BestPracticeNavigation() {
|
||||
return (
|
||||
<div className="mx-auto grid grid-cols-1 gap-6 px-2 md:grid-cols-3">
|
||||
{BestPractices.map((bestPractice) => (
|
||||
<Link href={bestPractice.href} key={bestPractice.name}>
|
||||
<div className="drop-shadow-card duration-120 hover:border-brand-dark relative rounded-lg border border-slate-100 bg-slate-100 p-6 transition-all ease-in-out hover:scale-105 hover:cursor-pointer dark:border-slate-600 dark:bg-slate-800">
|
||||
<Link className="relative block" href={bestPractice.href} key={bestPractice.name}>
|
||||
<div className="drop-shadow-card duration-120 hover:border-brand-dark relative h-full rounded-lg border border-slate-100 bg-slate-100 p-6 transition-all ease-in-out hover:scale-105 hover:cursor-pointer dark:border-slate-600 dark:bg-slate-800">
|
||||
<div
|
||||
className={clsx(
|
||||
// base styles independent what type of button it is
|
||||
@@ -105,7 +105,9 @@ export default function BestPracticeNavigation() {
|
||||
<h3 className="mb-1 mt-3 text-xl font-bold text-slate-700 dark:text-slate-200">
|
||||
{bestPractice.name}
|
||||
</h3>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400">{bestPractice.description}</p>
|
||||
<p className="flex self-end text-sm text-slate-600 dark:text-slate-400">
|
||||
{bestPractice.description}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
|
||||
@@ -15,23 +15,23 @@
|
||||
"@formbricks/ui": "workspace:*",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"storybook": "^7.4.6"
|
||||
"storybook": "^7.6.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@formbricks/tsconfig": "workspace:*",
|
||||
"@storybook/addon-essentials": "^7.5.0-alpha.5",
|
||||
"@storybook/addon-interactions": "^7.5.0-alpha.5",
|
||||
"@storybook/addon-links": "^7.5.0-alpha.5",
|
||||
"@storybook/addon-onboarding": "^1.0.8",
|
||||
"@storybook/blocks": "^7.5.0-alpha.5",
|
||||
"@storybook/react": "^7.5.0-alpha.5",
|
||||
"@storybook/react-vite": "^7.5.0-alpha.5",
|
||||
"@storybook/addon-essentials": "^7.6.3",
|
||||
"@storybook/addon-interactions": "^7.6.3",
|
||||
"@storybook/addon-links": "^7.6.3",
|
||||
"@storybook/addon-onboarding": "^1.0.9",
|
||||
"@storybook/blocks": "^7.6.3",
|
||||
"@storybook/react": "^7.6.3",
|
||||
"@storybook/react-vite": "^7.6.3",
|
||||
"@storybook/testing-library": "^0.2.2",
|
||||
"@typescript-eslint/eslint-plugin": "^6.8.0",
|
||||
"@typescript-eslint/parser": "^6.8.0",
|
||||
"@vitejs/plugin-react": "^4.1.0",
|
||||
"esbuild": "^0.19.5",
|
||||
"tsup": "^7.2.0",
|
||||
"vite": "^4.4.11"
|
||||
"@typescript-eslint/eslint-plugin": "^6.13.2",
|
||||
"@typescript-eslint/parser": "^6.13.2",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"esbuild": "^0.19.8",
|
||||
"tsup": "^8.0.1",
|
||||
"vite": "^5.0.6"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
# Installer stage: Building the application
|
||||
FROM node:20-alpine AS installer
|
||||
|
||||
# Enable corepack and prepare pnpm
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
|
||||
# Install necessary build tools and compilers
|
||||
RUN apk update && apk add --no-cache g++ cmake make gcc python3 openssl-dev
|
||||
|
||||
# Install Supercronic (cron for containers without super user privileges)
|
||||
RUN apk add --no-cache curl \
|
||||
&& curl -fsSLo /tmp/supercronic \
|
||||
"https://github.com/aptible/supercronic/releases/download/v0.2.27/supercronic-linux-amd64" \
|
||||
&& chmod +x /tmp/supercronic
|
||||
|
||||
# Set environment variables
|
||||
ARG DATABASE_URL
|
||||
ENV DATABASE_URL=$DATABASE_URL
|
||||
|
||||
@@ -17,13 +23,19 @@ ENV NEXTAUTH_SECRET=$NEXTAUTH_SECRET
|
||||
ARG ENCRYPTION_KEY
|
||||
ENV ENCRYPTION_KEY=$ENCRYPTION_KEY
|
||||
|
||||
# Set the working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy the application files
|
||||
COPY . .
|
||||
|
||||
# Create a .env file
|
||||
RUN touch /app/apps/web/.env
|
||||
|
||||
# Install the dependencies
|
||||
RUN pnpm install
|
||||
|
||||
|
||||
# Build the project
|
||||
RUN pnpm post-install --filter=web...
|
||||
RUN pnpm turbo run build --filter=web...
|
||||
|
||||
@@ -38,6 +38,8 @@ export default function StylingCard({ localSurvey, setLocalSurvey, colours }: St
|
||||
productOverwrites: {
|
||||
...localSurvey.productOverwrites,
|
||||
placement: !!placement ? null : "bottomRight",
|
||||
clickOutsideClose: false,
|
||||
darkOverlay: false,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -16,6 +16,7 @@ import toast from "react-hot-toast";
|
||||
import { validateQuestion } from "./Validation";
|
||||
import { deleteSurveyAction, updateSurveyAction } from "../actions";
|
||||
import SurveyStatusDropdown from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/SurveyStatusDropdown";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@formbricks/ui/Tooltip";
|
||||
|
||||
interface SurveyMenuBarProps {
|
||||
localSurvey: TSurvey;
|
||||
@@ -48,6 +49,7 @@ export default function SurveyMenuBar({
|
||||
const [isSurveySaving, setIsSurveySaving] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const cautionText = "This survey received responses, make changes with caution.";
|
||||
|
||||
let faultyQuestions: String[] = [];
|
||||
|
||||
@@ -295,11 +297,20 @@ export default function SurveyMenuBar({
|
||||
/>
|
||||
</div>
|
||||
{responseCount > 0 && (
|
||||
<div className="mx-auto flex items-center rounded-full border border-amber-200 bg-amber-100 p-2 text-amber-700 shadow-sm">
|
||||
<ExclamationTriangleIcon className=" h-5 w-5 text-amber-400" />
|
||||
<p className=" pl-1 text-xs lg:text-sm">
|
||||
This survey received responses, make changes with caution.
|
||||
</p>
|
||||
<div className="ju flex items-center rounded-lg border border-amber-200 bg-amber-100 p-2 text-amber-700 shadow-sm lg:mx-auto">
|
||||
<TooltipProvider delayDuration={50}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<ExclamationTriangleIcon className=" h-5 w-5 text-amber-400" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side={"top"} className="lg:hidden">
|
||||
<p className="py-2 text-center text-xs text-slate-500 dark:text-slate-400 ">
|
||||
{cautionText}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<p className=" hidden pl-1 text-xs md:text-sm lg:block">{cautionText}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-3 flex sm:ml-4 sm:mt-0">
|
||||
|
||||
@@ -56,7 +56,7 @@ export default function Modal({
|
||||
ref={modalRef}
|
||||
style={highlightBorderColorStyle}
|
||||
className={cn(
|
||||
"pointer-events-auto absolute h-fit max-h-[90%] w-full max-w-sm overflow-y-auto rounded-lg bg-white shadow-lg ring-1 ring-black ring-opacity-5 transition-all duration-500 ease-in-out ",
|
||||
"pointer-events-auto absolute h-auto max-h-[90%] w-full max-w-sm overflow-y-auto rounded-lg bg-white shadow-lg ring-1 ring-black ring-opacity-5 transition-all duration-500 ease-in-out ",
|
||||
previewMode === "desktop" ? getPlacementStyle(placement) : "max-w-full ",
|
||||
slidingAnimationClass
|
||||
)}>
|
||||
|
||||
@@ -20,8 +20,8 @@ export const MediaBackground: React.FC<MediaBackgroundProps> = ({
|
||||
}) => {
|
||||
const getFilterStyle = () => {
|
||||
return survey.styling?.background?.brightness
|
||||
? `brightness-${survey.styling?.background?.brightness}`
|
||||
: "";
|
||||
? `brightness(${survey.styling?.background?.brightness}%)`
|
||||
: "brightness(100%)";
|
||||
};
|
||||
|
||||
const renderBackground = () => {
|
||||
@@ -32,21 +32,26 @@ export const MediaBackground: React.FC<MediaBackgroundProps> = ({
|
||||
case "color":
|
||||
return (
|
||||
<div
|
||||
className={`${baseClasses} ${filterStyle}`}
|
||||
style={{ backgroundColor: survey.styling?.background?.bg || "#ffff" }}
|
||||
className={`${baseClasses}`}
|
||||
style={{ backgroundColor: survey.styling?.background?.bg || "#ffff", filter: `${filterStyle}` }}
|
||||
/>
|
||||
);
|
||||
case "animation":
|
||||
return (
|
||||
<video muted loop autoPlay className={`${baseClasses} object-cover ${filterStyle}`}>
|
||||
<video
|
||||
muted
|
||||
loop
|
||||
autoPlay
|
||||
className={`${baseClasses} object-cover`}
|
||||
style={{ filter: `${filterStyle}` }}>
|
||||
<source src={survey.styling?.background?.bg || ""} type="video/mp4" />
|
||||
</video>
|
||||
);
|
||||
case "image":
|
||||
return (
|
||||
<div
|
||||
className={`${baseClasses} bg-cover bg-center ${filterStyle}`}
|
||||
style={{ backgroundImage: `url(${survey.styling?.background?.bg})` }}
|
||||
className={`${baseClasses} bg-cover bg-center`}
|
||||
style={{ backgroundImage: `url(${survey.styling?.background?.bg})`, filter: `${filterStyle}` }}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
|
||||
@@ -4,6 +4,11 @@ import "@formbricks/lib/env.mjs";
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
|
||||
function getHostname(url) {
|
||||
const urlObj = new URL(url);
|
||||
return urlObj.hostname;
|
||||
}
|
||||
|
||||
const nextConfig = {
|
||||
assetPrefix: process.env.ASSET_PREFIX_URL || undefined,
|
||||
output: "standalone",
|
||||
@@ -107,6 +112,10 @@ if (process.env.WEBAPP_URL) {
|
||||
nextConfig.experimental.serverActions = {
|
||||
allowedOrigins: [process.env.WEBAPP_URL.replace(/https?:\/\//, "")],
|
||||
};
|
||||
nextConfig.images.remotePatterns.push({
|
||||
protocol: "https",
|
||||
hostname: getHostname(process.env.WEBAPP_URL),
|
||||
});
|
||||
}
|
||||
|
||||
const sentryOptions = {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@formbricks/web",
|
||||
"version": "1.3.4",
|
||||
"version": "1.3.5",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"clean": "rimraf .turbo node_modules .next",
|
||||
@@ -25,13 +25,13 @@
|
||||
"@paralleldrive/cuid2": "^2.2.2",
|
||||
"@radix-ui/react-collapsible": "^1.0.3",
|
||||
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
||||
"@react-email/components": "^0.0.11",
|
||||
"@sentry/nextjs": "^7.84.0",
|
||||
"@react-email/components": "^0.0.12",
|
||||
"@sentry/nextjs": "^7.85.0",
|
||||
"@vercel/og": "^0.5.20",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"dotenv": "^16.3.1",
|
||||
"encoding": "^0.1.13",
|
||||
"framer-motion": "10.16.9",
|
||||
"framer-motion": "10.16.14",
|
||||
"googleapis": "^129.0.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"lodash": "^4.17.21",
|
||||
@@ -41,13 +41,13 @@
|
||||
"next": "13.5.6",
|
||||
"nodemailer": "^6.9.7",
|
||||
"otplib": "^12.0.1",
|
||||
"posthog-js": "^1.93.3",
|
||||
"posthog-js": "^1.93.6",
|
||||
"prismjs": "^1.29.0",
|
||||
"qrcode": "^1.5.3",
|
||||
"react": "18.2.0",
|
||||
"react-beautiful-dnd": "^13.1.1",
|
||||
"react-dom": "18.2.0",
|
||||
"react-email": "^1.9.5",
|
||||
"react-email": "^1.10.0",
|
||||
"react-hook-form": "^7.48.2",
|
||||
"react-hot-toast": "^2.4.1",
|
||||
"react-icons": "^4.12.0",
|
||||
|
||||
45
apps/web/playwright/onboarding.spec.ts
Normal file
45
apps/web/playwright/onboarding.spec.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { getTeam, getUser, signUpAndLogin } from "./utils";
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
const { role, productName, useCase } = getTeam();
|
||||
|
||||
test.describe("Onboarding Flow Test", async () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
const { name, email, password } = getUser();
|
||||
await signUpAndLogin(page, name, email, password);
|
||||
await page.waitForURL("/onboarding");
|
||||
await expect(page).toHaveURL("/onboarding");
|
||||
});
|
||||
|
||||
test("Step by Step", async ({ page }) => {
|
||||
await page.getByRole("button", { name: "Begin (1 min)" }).click();
|
||||
await page.getByLabel(role).check();
|
||||
await page.getByRole("button", { name: "Next" }).click();
|
||||
|
||||
await expect(page.getByLabel(useCase)).toBeVisible();
|
||||
await page.getByLabel(useCase).check();
|
||||
await page.getByRole("button", { name: "Next" }).click();
|
||||
|
||||
await expect(page.getByPlaceholder("e.g. Formbricks")).toBeVisible();
|
||||
await page.getByPlaceholder("e.g. Formbricks").fill(productName);
|
||||
|
||||
await page.locator(".h-6").click();
|
||||
await page.getByLabel("Hue").click();
|
||||
|
||||
await page.locator("div").filter({ hasText: "Create your team's product." }).nth(1).click();
|
||||
await page.getByRole("button", { name: "Done" }).click();
|
||||
|
||||
await page.waitForURL(/\/environments\/[^/]+\/surveys/);
|
||||
await expect(page).toHaveURL(/\/environments\/[^/]+\/surveys/);
|
||||
await expect(page.getByText(productName)).toBeVisible();
|
||||
});
|
||||
|
||||
test("Skip", async ({ page }) => {
|
||||
await page.getByRole("button", { name: "I'll do it later" }).click();
|
||||
await page.getByRole("button", { name: "I'll do it later" }).click();
|
||||
|
||||
await page.waitForURL(/\/environments\/[^/]+\/surveys/);
|
||||
await expect(page).toHaveURL(/\/environments\/[^/]+\/surveys/);
|
||||
await expect(page.getByText("My Product")).toBeVisible();
|
||||
});
|
||||
});
|
||||
61
apps/web/playwright/signup.spec.ts
Normal file
61
apps/web/playwright/signup.spec.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { getUser } from "./utils";
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
const { name, email, password } = getUser();
|
||||
|
||||
test.describe("Email Signup Flow Test", async () => {
|
||||
test.describe.configure({ mode: "serial" });
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/auth/signup");
|
||||
await page.getByText("Continue with Email").click();
|
||||
});
|
||||
|
||||
test("Valid User", async ({ page }) => {
|
||||
await page.fill('input[name="name"]', name);
|
||||
await page.fill('input[name="email"]', email);
|
||||
await page.fill('input[name="password"]', password);
|
||||
await page.press('input[name="password"]', "Enter");
|
||||
await page.waitForURL("/auth/signup-without-verification-success");
|
||||
await expect(page).toHaveURL("/auth/signup-without-verification-success");
|
||||
});
|
||||
|
||||
test("Email is taken", async ({ page }) => {
|
||||
await page.fill('input[name="name"]', name);
|
||||
await page.fill('input[name="email"]', email);
|
||||
await page.fill('input[name="password"]', password);
|
||||
await page.press('input[name="password"]', "Enter");
|
||||
|
||||
let alertMessage = "user with this email address already exists";
|
||||
await (await page.waitForSelector(`text=${alertMessage}`)).isVisible();
|
||||
});
|
||||
|
||||
test("No Name", async ({ page }) => {
|
||||
await page.fill('input[name="name"]', "");
|
||||
await page.fill('input[name="email"]', email);
|
||||
await page.fill('input[name="password"]', password);
|
||||
await page.press('input[name="password"]', "Enter");
|
||||
|
||||
const button = page.getByText("Continue with Email");
|
||||
await expect(button).toBeDisabled();
|
||||
});
|
||||
|
||||
test("Invalid Email", async ({ page }) => {
|
||||
await page.fill('input[name="name"]', name);
|
||||
await page.fill('input[name="email"]', "invalid");
|
||||
await page.fill('input[name="password"]', password);
|
||||
await page.press('input[name="password"]', "Enter");
|
||||
|
||||
const button = page.getByText("Continue with Email");
|
||||
await expect(button).toBeDisabled();
|
||||
});
|
||||
|
||||
test("Invalid Password", async ({ page }) => {
|
||||
await page.fill('input[name="name"]', name);
|
||||
await page.fill('input[name="email"]', email);
|
||||
await page.fill('input[name="password"]', "invalid");
|
||||
await page.press('input[name="password"]', "Enter");
|
||||
|
||||
const button = page.getByText("Continue with Email");
|
||||
await expect(button).toBeDisabled();
|
||||
});
|
||||
});
|
||||
98
apps/web/playwright/survey.spec.ts
Normal file
98
apps/web/playwright/survey.spec.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { getUser, login, signUpAndLogin, skipOnboarding } from "./utils";
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test.describe("Survey: Product Market Fit", async () => {
|
||||
test.describe.configure({ mode: "serial" });
|
||||
let url: string | null;
|
||||
const { name, email, password } = getUser();
|
||||
|
||||
test("Create Survey", async ({ page }) => {
|
||||
await signUpAndLogin(page, name, email, password);
|
||||
await skipOnboarding(page);
|
||||
|
||||
await page.getByRole("heading", { name: "Product Market Fit (Superhuman)" }).click();
|
||||
await page.getByRole("button", { name: "Continue to Settings" }).click();
|
||||
await page.getByRole("button", { name: "Publish" }).click();
|
||||
|
||||
const regexPattern = /^http:\/\/localhost:3000\/s\//;
|
||||
const urlElement = page.locator(`text=${regexPattern}`);
|
||||
|
||||
await expect(urlElement).toBeVisible();
|
||||
url = await urlElement.textContent();
|
||||
await page.getByRole("button", { name: "Close" }).click();
|
||||
});
|
||||
|
||||
test("Submit Response: No", async ({ page }) => {
|
||||
await page.goto(url!);
|
||||
|
||||
await expect(page.getByText("You are one of our power users! Do you have 5 minutes?")).toBeVisible();
|
||||
await expect(page.getByText("Optional")).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "No, thanks." }).click();
|
||||
|
||||
await expect(page.getByText("Thank you!")).toBeVisible();
|
||||
await expect(page.getByText("We appreciate your feedback.")).toBeVisible();
|
||||
});
|
||||
|
||||
test("Submit Response: Yes", async ({ page }) => {
|
||||
await page.goto(url!);
|
||||
|
||||
await expect(page.getByText("You are one of our power users! Do you have 5 minutes?")).toBeVisible();
|
||||
await expect(page.getByText("Optional")).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Happy to help!" }).click();
|
||||
|
||||
await expect(
|
||||
page.getByText("How disappointed would you be if you could no longer use My Product?")
|
||||
).toBeVisible();
|
||||
let answers = ["Not at all disappointed", "Somewhat disappointed", "Very disappointed"];
|
||||
let answer = answers[Math.floor(Math.random() * answers.length)];
|
||||
await page.getByText(answer).click();
|
||||
await page.getByRole("button", { name: "Next" }).click();
|
||||
|
||||
await expect(page.getByText("What is your role?")).toBeVisible();
|
||||
answers = ["Founder", "Executive", "Product Manager", "Product Owner", "Software Engineer"];
|
||||
answer = answers[Math.floor(Math.random() * answers.length)];
|
||||
await page.getByText(answer).click();
|
||||
await page.getByRole("button", { name: "Next" }).click();
|
||||
|
||||
await expect(
|
||||
page.getByText("What type of people do you think would most benefit from My Product?")
|
||||
).toBeVisible();
|
||||
await page.getByLabel("").fill("Founders and Executives");
|
||||
await page.getByRole("button", { name: "Next" }).click();
|
||||
|
||||
await expect(page.getByText("What is the main benefit you")).toBeVisible();
|
||||
await page.getByLabel("").fill("Open Source and the UX!");
|
||||
await page.getByRole("button", { name: "Next" }).click();
|
||||
|
||||
await expect(page.getByText("How can we improve My Product")).toBeVisible();
|
||||
await page.getByLabel("").fill("More and more features, that's it! Keep shipping.");
|
||||
await page.getByRole("button", { name: "Finish" }).click();
|
||||
|
||||
await expect(page.getByText("Thank you!")).toBeVisible();
|
||||
await expect(page.getByText("We appreciate your feedback.")).toBeVisible();
|
||||
});
|
||||
|
||||
test("View Responses", async ({ page }) => {
|
||||
await login(page, email, password);
|
||||
await page.waitForURL(/\/environments\/[^/]+\/surveys/);
|
||||
await expect(page.getByText("Your Surveys")).toBeVisible();
|
||||
await page.locator("li").filter({ hasText: "Link SurveyProduct Market Fit" }).getByRole("link").click();
|
||||
await expect(page.getByText("Responses")).toBeVisible();
|
||||
await expect(page.getByText("Product Market Fit (Superhuman)")).toBeVisible();
|
||||
await expect(page.getByText("Displays2")).toBeVisible();
|
||||
await page.getByRole("button", { name: "Responses100%" }).textContent();
|
||||
await page.getByRole("link", { name: "Responses" }).click();
|
||||
await page.waitForURL(/\/environments\/[^/]+\/surveys\/[^/]+\/responses/);
|
||||
expect(await page.locator(".rounded-b-lg").count()).toEqual(2);
|
||||
|
||||
await expect(
|
||||
page.getByText("You are one of our power users! Do you have 5 minutes?dismissed")
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByText("You are one of our power users! Do you have 5 minutes?clicked")
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
65
apps/web/playwright/utils.ts
Normal file
65
apps/web/playwright/utils.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { randomBytes } from "crypto";
|
||||
import { Page } from "playwright";
|
||||
import { expect } from "@playwright/test";
|
||||
|
||||
export const getUser = () => {
|
||||
const name = randomBytes(4).toString("hex");
|
||||
const email = `${name}@gmail.com`;
|
||||
const password = `Te${name}@123`;
|
||||
return { name, email, password };
|
||||
};
|
||||
|
||||
export const getTeam = () => {
|
||||
let roles = ["Project Manager", "Engineer", "Founder", "Marketing Specialist"];
|
||||
let useCases = [
|
||||
"Increase conversion",
|
||||
"Improve user retention",
|
||||
"Increase user adoption",
|
||||
"Sharpen marketing messaging",
|
||||
"Support sales",
|
||||
];
|
||||
const productName = randomBytes(8).toString("hex");
|
||||
const role = roles[Math.floor(Math.random() * roles.length)];
|
||||
const useCase = useCases[Math.floor(Math.random() * useCases.length)];
|
||||
return { role, useCase, productName };
|
||||
};
|
||||
|
||||
export const signUpAndLogin = async (
|
||||
page: Page,
|
||||
name: string,
|
||||
email: string,
|
||||
password: string
|
||||
): Promise<void> => {
|
||||
await page.goto("/auth/login");
|
||||
await page.getByRole("link", { name: "Create an account" }).click();
|
||||
await page.getByRole("button", { name: "Continue with Email" }).click();
|
||||
await page.getByPlaceholder("Full Name").fill(name);
|
||||
await page.getByPlaceholder("work@email.com").fill(email);
|
||||
await page.getByPlaceholder("*******").fill(password);
|
||||
await page.press('input[name="password"]', "Enter");
|
||||
await page.getByRole("link", { name: "Login" }).click();
|
||||
await page.getByRole("button", { name: "Login with Email" }).click();
|
||||
await page.getByPlaceholder("work@email.com").fill(email);
|
||||
await page.getByPlaceholder("*******").click();
|
||||
await page.getByPlaceholder("*******").fill(password);
|
||||
await page.getByRole("button", { name: "Login with Email" }).click();
|
||||
};
|
||||
|
||||
export const login = async (page: Page, email: string, password: string): Promise<void> => {
|
||||
await page.goto("/auth/login");
|
||||
await page.getByRole("button", { name: "Login with Email" }).click();
|
||||
await page.getByPlaceholder("work@email.com").fill(email);
|
||||
await page.getByPlaceholder("*******").click();
|
||||
await page.getByPlaceholder("*******").fill(password);
|
||||
await page.getByRole("button", { name: "Login with Email" }).click();
|
||||
};
|
||||
|
||||
export const skipOnboarding = async (page: Page): Promise<void> => {
|
||||
await page.waitForURL("/onboarding");
|
||||
await expect(page).toHaveURL("/onboarding");
|
||||
await page.getByRole("button", { name: "I'll do it later" }).click();
|
||||
await page.getByRole("button", { name: "I'll do it later" }).click();
|
||||
await page.waitForURL(/\/environments\/[^/]+\/surveys/);
|
||||
await expect(page).toHaveURL(/\/environments\/[^/]+\/surveys/);
|
||||
await expect(page.getByText("My Product")).toBeVisible();
|
||||
};
|
||||
@@ -24,18 +24,15 @@ x-encryption-key: &encryption_key 1b3d888592454d23b520040950654669
|
||||
|
||||
x-mail-from: &mail_from
|
||||
x-smtp-host: &smtp_host
|
||||
x-smtp-port: &smtp_port # Enable SMTP_SECURE_ENABLED for TLS (port 465)
|
||||
x-smtp-port: &smtp_port
|
||||
x-smtp-secure-enabled: &smtp_secure_enabled # Enable SMTP_SECURE_ENABLED for TLS (port 465)
|
||||
|
||||
|
||||
x-smtp-secure-enabled: &smtp_secure_enabled
|
||||
x-smtp-user: &smtp_user
|
||||
x-smtp-password: &smtp_password
|
||||
# Set the below value to your public-facing URL, e.g., https://example.com
|
||||
|
||||
# Set the below value if you have and want to share a shorter base URL than the x-survey-base-url
|
||||
|
||||
x-short-url-base:
|
||||
&short_url_base # Email Verification. If you enable Email Verification you have to setup SMTP-Settings, too.
|
||||
&short_url_base # Set the below value if you have and want to share a shorter base URL than the x-survey-base-url
|
||||
|
||||
|
||||
x-email-verification-disabled: &email_verification_disabled 1
|
||||
@@ -52,23 +49,21 @@ x-invite-disabled: &invite_disabled 0
|
||||
# Set the below values to display privacy policy, imprint and terms of service links in the footer of signup & public pages.
|
||||
x-privacy-url: &privacy_url
|
||||
x-terms-url: &terms_url
|
||||
x-imprint-url: &imprint_url # Configure Github Login
|
||||
x-imprint-url: &imprint_url
|
||||
|
||||
|
||||
x-github-auth-enabled: &github_auth_enabled 0
|
||||
x-github-auth-enabled: &github_auth_enabled 0 # Configure Github Login
|
||||
x-github-id: &github_id
|
||||
x-github-secret: &github_secret # Configure Google Login
|
||||
x-github-secret: &github_secret
|
||||
|
||||
x-google-auth-enabled: &google_auth_enabled 0
|
||||
x-google-auth-enabled: &google_auth_enabled 0 # Configure Google Login
|
||||
x-google-client-id: &google_client_id
|
||||
x-google-client-secret: &google_client_secret # Disable Sentry warning
|
||||
x-google-client-secret: &google_client_secret
|
||||
|
||||
x-sentry-ignore-api-resolution-error: &sentry_ignore_api_resolution_error # Enable Sentry Error Tracking
|
||||
x-sentry-ignore-api-resolution-error: &sentry_ignore_api_resolution_error # Disable Sentry warning
|
||||
|
||||
x-next-public-sentry-dsn: &next_public_sentry_dsn # Cron Secret
|
||||
x-next-public-sentry-dsn: &next_public_sentry_dsn # Enable Sentry Error Tracking
|
||||
|
||||
# Set this to a random string to secure your cron endpoints
|
||||
x-cron-secret: &cron_secret YOUR_CRON_SECRET
|
||||
x-cron-secret: &cron_secret YOUR_CRON_SECRET # Set this to a random string to secure your cron endpoints
|
||||
|
||||
services:
|
||||
postgres:
|
||||
|
||||
@@ -26,12 +26,12 @@
|
||||
"lint": "turbo run lint",
|
||||
"release": "turbo run build --filter=js... && turbo run build --filter=n8n-node... && changeset publish",
|
||||
"test": "turbo run test",
|
||||
"test:e2e": "playwright test",
|
||||
"prepare": "husky install"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@changesets/cli": "^2.26.2",
|
||||
"@playwright/test": "^1.40.1",
|
||||
"@types/node": "20.10.1",
|
||||
"eslint-config-formbricks": "workspace:*",
|
||||
"husky": "^8.0.3",
|
||||
"lint-staged": "^15.1.0",
|
||||
|
||||
@@ -33,10 +33,10 @@
|
||||
"devDependencies": {
|
||||
"@formbricks/types": "workspace:*",
|
||||
"@formbricks/tsconfig": "workspace:*",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-config-turbo": "latest",
|
||||
"terser": "^5.24.0",
|
||||
"vite": "^5.0.4",
|
||||
"terser": "^5.25.0",
|
||||
"vite": "^5.0.6",
|
||||
"vite-plugin-dts": "^3.6.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,6 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@formbricks/lib": "workspace:*",
|
||||
"stripe": "^14.6.0"
|
||||
"stripe": "^14.7.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,13 +8,13 @@
|
||||
"clean": "rimraf node_modules .turbo"
|
||||
},
|
||||
"devDependencies": {
|
||||
"eslint": "^8.54.0",
|
||||
"eslint": "^8.55.0",
|
||||
"eslint-config-next": "^14.0.3",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-config-turbo": "latest",
|
||||
"eslint-plugin-react": "7.33.2",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.4",
|
||||
"eslint-plugin-react-refresh": "^0.4.5",
|
||||
"eslint-plugin-storybook": "^0.6.15"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@formbricks/js",
|
||||
"license": "MIT",
|
||||
"version": "1.2.6",
|
||||
"version": "1.2.7",
|
||||
"description": "Formbricks-js allows you to connect your app to Formbricks, display surveys and trigger events.",
|
||||
"keywords": [
|
||||
"Formbricks",
|
||||
@@ -42,19 +42,19 @@
|
||||
"@formbricks/surveys": "workspace:*",
|
||||
"@formbricks/tsconfig": "workspace:*",
|
||||
"@formbricks/types": "workspace:*",
|
||||
"@types/jest": "^29.5.10",
|
||||
"@typescript-eslint/eslint-plugin": "^6.13.1",
|
||||
"@typescript-eslint/parser": "^6.13.1",
|
||||
"@types/jest": "^29.5.11",
|
||||
"@typescript-eslint/eslint-plugin": "^6.13.2",
|
||||
"@typescript-eslint/parser": "^6.13.2",
|
||||
"babel-jest": "^29.7.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"isomorphic-fetch": "^3.0.0",
|
||||
"jest": "^29.7.0",
|
||||
"jest-environment-jsdom": "^29.7.0",
|
||||
"jest-fetch-mock": "^3.0.3",
|
||||
"terser": "^5.24.0",
|
||||
"vite": "^5.0.4",
|
||||
"terser": "^5.25.0",
|
||||
"vite": "^5.0.6",
|
||||
"vite-plugin-dts": "^3.6.4",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-config-turbo": "latest"
|
||||
},
|
||||
"jest": {
|
||||
|
||||
@@ -7,6 +7,13 @@ export class Config {
|
||||
private static instance: Config | undefined;
|
||||
private config: TJsConfig | null = null;
|
||||
|
||||
private constructor() {
|
||||
const localConfig = this.loadFromLocalStorage();
|
||||
if (localConfig.ok) {
|
||||
this.config = localConfig.value;
|
||||
}
|
||||
}
|
||||
|
||||
static getInstance(): Config {
|
||||
if (!Config.instance) {
|
||||
Config.instance = new Config();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { TJsConfigInput } from "@formbricks/types/js";
|
||||
import type { TJsConfig, TJsConfigInput } from "@formbricks/types/js";
|
||||
import { Config } from "./config";
|
||||
import {
|
||||
ErrorHandler,
|
||||
@@ -16,6 +16,8 @@ import { checkPageUrl } from "./noCodeActions";
|
||||
import { sync } from "./sync";
|
||||
import { addWidgetContainer, closeSurvey } from "./widget";
|
||||
import { trackAction } from "./actions";
|
||||
import { updatePersonAttributes } from "./person";
|
||||
import { TPersonAttributes } from "@formbricks/types/people";
|
||||
|
||||
const config = Config.getInstance();
|
||||
const logger = Logger.getInstance();
|
||||
@@ -63,19 +65,44 @@ export const initialize = async (
|
||||
logger.debug("Adding widget container to DOM");
|
||||
addWidgetContainer();
|
||||
|
||||
const localConfigResult = config.loadFromLocalStorage();
|
||||
if (!c.userId && c.attributes) {
|
||||
logger.error("No userId provided but attributes. Cannot update attributes without userId.");
|
||||
return err({
|
||||
code: "missing_field",
|
||||
field: "userId",
|
||||
});
|
||||
}
|
||||
|
||||
// if userId and attributes are available, set them in backend
|
||||
let updatedAttributes: TPersonAttributes | null = null;
|
||||
if (c.userId && c.attributes) {
|
||||
const res = await updatePersonAttributes(c.apiHost, c.environmentId, c.userId, c.attributes);
|
||||
|
||||
if (res.ok !== true) {
|
||||
return err(res.error);
|
||||
}
|
||||
updatedAttributes = res.value;
|
||||
}
|
||||
|
||||
let existingConfig: TJsConfig | undefined;
|
||||
try {
|
||||
existingConfig = config.get();
|
||||
} catch (e) {
|
||||
logger.debug("No existing configuration found.");
|
||||
}
|
||||
|
||||
if (
|
||||
localConfigResult.ok &&
|
||||
localConfigResult.value.state &&
|
||||
localConfigResult.value.environmentId === c.environmentId &&
|
||||
localConfigResult.value.apiHost === c.apiHost &&
|
||||
localConfigResult.value.userId === c.userId &&
|
||||
localConfigResult.value.expiresAt // only accept config when they follow new config version with expiresAt
|
||||
existingConfig &&
|
||||
existingConfig.state &&
|
||||
existingConfig.environmentId === c.environmentId &&
|
||||
existingConfig.apiHost === c.apiHost &&
|
||||
existingConfig.userId === c.userId &&
|
||||
existingConfig.expiresAt // only accept config when they follow new config version with expiresAt
|
||||
) {
|
||||
logger.debug("Found existing configuration.");
|
||||
if (localConfigResult.value.expiresAt < new Date()) {
|
||||
if (existingConfig.expiresAt < new Date()) {
|
||||
logger.debug("Configuration expired.");
|
||||
|
||||
await sync({
|
||||
apiHost: c.apiHost,
|
||||
environmentId: c.environmentId,
|
||||
@@ -83,14 +110,12 @@ export const initialize = async (
|
||||
});
|
||||
} else {
|
||||
logger.debug("Configuration not expired. Extending expiration.");
|
||||
config.update(localConfigResult.value);
|
||||
config.update(existingConfig);
|
||||
}
|
||||
} else {
|
||||
logger.debug("No valid configuration found or it has been expired. Creating new config.");
|
||||
logger.debug("Syncing.");
|
||||
|
||||
// when the local storage is expired / empty, we sync to get the latest config
|
||||
|
||||
await sync({
|
||||
apiHost: c.apiHost,
|
||||
environmentId: c.environmentId,
|
||||
@@ -98,7 +123,20 @@ export const initialize = async (
|
||||
});
|
||||
|
||||
// and track the new session event
|
||||
trackAction("New Session");
|
||||
await trackAction("New Session");
|
||||
}
|
||||
|
||||
// update attributes in config
|
||||
if (updatedAttributes && Object.keys(updatedAttributes).length > 0) {
|
||||
config.update({
|
||||
environmentId: config.get().environmentId,
|
||||
apiHost: config.get().apiHost,
|
||||
userId: config.get().userId,
|
||||
state: {
|
||||
...config.get().state,
|
||||
attributes: { ...config.get().state.attributes, ...c.attributes },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
logger.debug("Adding event listeners");
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import { FormbricksAPI } from "@formbricks/api";
|
||||
import { TPersonUpdateInput } from "@formbricks/types/people";
|
||||
import { TPersonAttributes, TPersonUpdateInput } from "@formbricks/types/people";
|
||||
import { Config } from "./config";
|
||||
import { AttributeAlreadyExistsError, MissingPersonError, NetworkError, Result, err, okVoid } from "./errors";
|
||||
import {
|
||||
AttributeAlreadyExistsError,
|
||||
MissingPersonError,
|
||||
NetworkError,
|
||||
Result,
|
||||
err,
|
||||
ok,
|
||||
okVoid,
|
||||
} from "./errors";
|
||||
import { deinitalize, initialize } from "./initialize";
|
||||
import { Logger } from "./logger";
|
||||
import { sync } from "./sync";
|
||||
@@ -55,6 +63,66 @@ export const updatePersonAttribute = async (
|
||||
return okVoid();
|
||||
};
|
||||
|
||||
export const updatePersonAttributes = async (
|
||||
apiHost: string,
|
||||
environmentId: string,
|
||||
userId: string,
|
||||
attributes: TPersonAttributes
|
||||
): Promise<Result<TPersonAttributes, NetworkError | MissingPersonError>> => {
|
||||
if (!userId) {
|
||||
return err({
|
||||
code: "missing_person",
|
||||
message: "Unable to update attribute. User identification deactivated. No userId set.",
|
||||
});
|
||||
}
|
||||
|
||||
// clean attributes and remove existing attributes if config already exists
|
||||
const updatedAttributes = { ...attributes };
|
||||
try {
|
||||
const existingAttributes = config.get()?.state?.attributes;
|
||||
if (existingAttributes) {
|
||||
for (const [key, value] of Object.entries(existingAttributes)) {
|
||||
if (updatedAttributes[key] === value) {
|
||||
delete updatedAttributes[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
logger.debug("config not set; sending all attributes to backend");
|
||||
}
|
||||
|
||||
// send to backend if updatedAttributes is not empty
|
||||
if (Object.keys(updatedAttributes).length === 0) {
|
||||
logger.debug("No attributes to update. Skipping update.");
|
||||
return ok(updatedAttributes);
|
||||
}
|
||||
|
||||
logger.debug("Updating attributes: " + JSON.stringify(updatedAttributes));
|
||||
|
||||
const input: TPersonUpdateInput = {
|
||||
attributes: updatedAttributes,
|
||||
};
|
||||
|
||||
const api = new FormbricksAPI({
|
||||
apiHost,
|
||||
environmentId,
|
||||
});
|
||||
|
||||
const res = await api.client.people.update(userId, input);
|
||||
|
||||
if (res.ok) {
|
||||
return ok(updatedAttributes);
|
||||
}
|
||||
|
||||
return err({
|
||||
code: "network_error",
|
||||
status: 500,
|
||||
message: `Error updating person with userId ${userId}`,
|
||||
url: `${apiHost}/api/v1/client/${environmentId}/people/${userId}`,
|
||||
responseMessage: res.error.message,
|
||||
});
|
||||
};
|
||||
|
||||
export const isExistingAttribute = (key: string, value: string): boolean => {
|
||||
if (config.get().state.attributes[key] === value) {
|
||||
return true;
|
||||
|
||||
@@ -12,9 +12,9 @@
|
||||
"lint:report": "eslint . --format json --output-file ../../lint-results/app-store.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/s3-presigned-post": "3.458.0",
|
||||
"@aws-sdk/client-s3": "3.458.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.458.0",
|
||||
"@aws-sdk/s3-presigned-post": "3.465.0",
|
||||
"@aws-sdk/client-s3": "3.465.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.465.0",
|
||||
"@t3-oss/env-nextjs": "^0.7.1",
|
||||
"mime": "4.0.0",
|
||||
"@formbricks/api": "*",
|
||||
@@ -25,12 +25,12 @@
|
||||
"date-fns": "^2.30.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"markdown-it": "^13.0.2",
|
||||
"nanoid": "^5.0.3",
|
||||
"nanoid": "^5.0.4",
|
||||
"next-auth": "^4.24.5",
|
||||
"nodemailer": "^6.9.7",
|
||||
"posthog-node": "^3.1.3",
|
||||
"posthog-node": "^3.2.0",
|
||||
"server-only": "^0.0.1",
|
||||
"tailwind-merge": "^2.0.0"
|
||||
"tailwind-merge": "^2.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@formbricks/tsconfig": "*",
|
||||
|
||||
@@ -14,10 +14,11 @@ import { Prisma } from "@prisma/client";
|
||||
import { unstable_cache } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { SERVICES_REVALIDATION_INTERVAL } from "../constants";
|
||||
import { updateMembership } from "../membership/service";
|
||||
import { deleteTeam } from "../team/service";
|
||||
import { validateInputs } from "../utils/validate";
|
||||
import { profileCache } from "./cache";
|
||||
import { updateMembership } from "../membership/service";
|
||||
import { formatProfileDateFields } from "./util";
|
||||
|
||||
const responseSelection = {
|
||||
id: true,
|
||||
@@ -34,8 +35,8 @@ const responseSelection = {
|
||||
};
|
||||
|
||||
// function to retrive basic information about a user's profile
|
||||
export const getProfile = async (id: string): Promise<TProfile | null> =>
|
||||
unstable_cache(
|
||||
export const getProfile = async (id: string): Promise<TProfile | null> => {
|
||||
const profile = await unstable_cache(
|
||||
async () => {
|
||||
validateInputs([id, ZId]);
|
||||
|
||||
@@ -67,8 +68,18 @@ export const getProfile = async (id: string): Promise<TProfile | null> =>
|
||||
}
|
||||
)();
|
||||
|
||||
export const getProfileByEmail = async (email: string): Promise<TProfile | null> =>
|
||||
unstable_cache(
|
||||
if (!profile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...profile,
|
||||
...formatProfileDateFields(profile),
|
||||
} as TProfile;
|
||||
};
|
||||
|
||||
export const getProfileByEmail = async (email: string): Promise<TProfile | null> => {
|
||||
const profile = await unstable_cache(
|
||||
async () => {
|
||||
validateInputs([email, z.string().email()]);
|
||||
|
||||
@@ -100,6 +111,16 @@ export const getProfileByEmail = async (email: string): Promise<TProfile | null>
|
||||
}
|
||||
)();
|
||||
|
||||
if (!profile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...profile,
|
||||
...formatProfileDateFields(profile),
|
||||
} as TProfile;
|
||||
};
|
||||
|
||||
const getAdminMemberships = (memberships: TMembership[]): TMembership[] =>
|
||||
memberships.filter((membership) => membership.role === "admin");
|
||||
|
||||
|
||||
15
packages/lib/profile/util.ts
Normal file
15
packages/lib/profile/util.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { TProfile } from "@formbricks/types/profile";
|
||||
|
||||
export const formatProfileDateFields = (profile: TProfile): TProfile => {
|
||||
if (typeof profile.createdAt === "string") {
|
||||
profile.createdAt = new Date(profile.createdAt);
|
||||
}
|
||||
if (typeof profile.updatedAt === "string") {
|
||||
profile.updatedAt = new Date(profile.updatedAt);
|
||||
}
|
||||
if (typeof profile.emailVerified === "string") {
|
||||
profile.emailVerified = new Date(profile.emailVerified);
|
||||
}
|
||||
|
||||
return profile;
|
||||
};
|
||||
@@ -1,8 +1,8 @@
|
||||
import "server-only";
|
||||
|
||||
import { TResponseDates, TResponseTtc } from "@formbricks/types/responses";
|
||||
import { TResponse, TResponseTtc } from "@formbricks/types/responses";
|
||||
|
||||
export const formatResponseDateFields = (response: TResponseDates): TResponseDates => {
|
||||
export const formatResponseDateFields = (response: TResponse): TResponse => {
|
||||
if (typeof response.createdAt === "string") {
|
||||
response.createdAt = new Date(response.createdAt);
|
||||
}
|
||||
|
||||
@@ -28,14 +28,14 @@
|
||||
"@formbricks/types": "workspace:*",
|
||||
"@preact/preset-vite": "^2.7.0",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"postcss": "^8.4.31",
|
||||
"postcss": "^8.4.32",
|
||||
"preact": "^10.19.2",
|
||||
"tailwindcss": "^3.3.5",
|
||||
"terser": "^5.24.0",
|
||||
"vite": "^5.0.4",
|
||||
"tailwindcss": "^3.3.6",
|
||||
"terser": "^5.25.0",
|
||||
"vite": "^5.0.6",
|
||||
"vite-plugin-dts": "^3.6.4",
|
||||
"vite-tsconfig-paths": "^4.2.1",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-config-turbo": "latest"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"@tailwindcss/forms": "^0.5.7",
|
||||
"@tailwindcss/typography": "^0.5.10",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"postcss": "^8.4.31",
|
||||
"tailwindcss": "^3.3.5"
|
||||
"postcss": "^8.4.32",
|
||||
"tailwindcss": "^3.3.6"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
"clean": "rimraf node_modules dist turbo"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "20.10.1",
|
||||
"@types/react": "18.2.39",
|
||||
"@types/node": "20.10.3",
|
||||
"@types/react": "18.2.42",
|
||||
"@types/react-dom": "18.2.17",
|
||||
"typescript": "^5.3.2"
|
||||
}
|
||||
|
||||
@@ -96,6 +96,7 @@ export const ZJsConfigInput = z.object({
|
||||
debug: z.boolean().optional(),
|
||||
errorHandler: z.function().args(z.any()).returns(z.void()).optional(),
|
||||
userId: z.string().optional(),
|
||||
attributes: ZPersonAttributes.optional(),
|
||||
});
|
||||
|
||||
export type TJsConfigInput = z.infer<typeof ZJsConfigInput>;
|
||||
|
||||
@@ -66,12 +66,6 @@ export const ZResponse = z.object({
|
||||
|
||||
export type TResponse = z.infer<typeof ZResponse>;
|
||||
|
||||
export type TResponseDates = {
|
||||
createdAt: TResponse["createdAt"];
|
||||
updatedAt: TResponse["updatedAt"];
|
||||
notes: TResponse["notes"];
|
||||
};
|
||||
|
||||
export const ZResponseInput = z.object({
|
||||
environmentId: z.string().cuid2(),
|
||||
surveyId: z.string().cuid2(),
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"@formbricks/types": "workspace:*",
|
||||
"concurrently": "^8.2.2",
|
||||
"eslint-config-formbricks": "workspace:*",
|
||||
"postcss": "^8.4.31",
|
||||
"postcss": "^8.4.32",
|
||||
"react": "18.2.0"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -47,7 +47,7 @@
|
||||
"react-day-picker": "^8.9.1",
|
||||
"react-hot-toast": "^2.4.1",
|
||||
"react-radio-group": "^3.0.3",
|
||||
"react-use": "^17.4.1",
|
||||
"react-use": "^17.4.2",
|
||||
"mime": "^4.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,21 +10,19 @@ import { defineConfig, devices } from "@playwright/test";
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: "./tests",
|
||||
testDir: "./apps/web/playwright",
|
||||
/* Run tests in files in parallel */
|
||||
fullyParallel: true,
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
forbidOnly: !!process.env.CI,
|
||||
/* Retry on CI only */
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
retries: 2,
|
||||
/* Opt out of parallel tests on CI. */
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
workers: 1,
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: "html",
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||
// baseURL: 'http://127.0.0.1:3000',
|
||||
baseURL: "http://localhost:3000",
|
||||
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
trace: "on-first-retry",
|
||||
@@ -49,29 +47,22 @@ export default defineConfig({
|
||||
|
||||
/* Test against mobile viewports. */
|
||||
// {
|
||||
// name: 'Mobile Chrome',
|
||||
// use: { ...devices['Pixel 5'] },
|
||||
// name: "Mobile Chrome",
|
||||
// use: { ...devices["Pixel 5"] },
|
||||
// },
|
||||
// {
|
||||
// name: 'Mobile Safari',
|
||||
// use: { ...devices['iPhone 12'] },
|
||||
// name: "Mobile Safari",
|
||||
// use: { ...devices["iPhone 12"] },
|
||||
// },
|
||||
|
||||
/* Test against branded browsers. */
|
||||
// {
|
||||
// name: 'Microsoft Edge',
|
||||
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
|
||||
// name: "Microsoft Edge",
|
||||
// use: { ...devices["Desktop Edge"], channel: "msedge" },
|
||||
// },
|
||||
// {
|
||||
// name: 'Google Chrome',
|
||||
// use: { ...devices['Desktop Chrome'], channel: 'chrome' },
|
||||
// name: "Google Chrome",
|
||||
// use: { ...devices["Desktop Chrome"], channel: "chrome" },
|
||||
// },
|
||||
],
|
||||
|
||||
/* Run your local dev server before starting the tests */
|
||||
webServer: {
|
||||
command: "pnpm go",
|
||||
url: "http://127.0.0.1:3000",
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
});
|
||||
|
||||
3356
pnpm-lock.yaml
generated
3356
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,41 +0,0 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
const email = "testp@gmail.com";
|
||||
const password = "Test@123";
|
||||
|
||||
async function createUser(page) {
|
||||
await page.goto("http://localhost:3000/auth/signup");
|
||||
await page.getByText("Continue with Email").click();
|
||||
await page.fill('input[name="name"]', "test");
|
||||
await page.press('input[name="name"]', "Tab");
|
||||
await page.fill('input[name="email"]', email);
|
||||
await page.press('input[name="email"]', "Tab");
|
||||
await page.fill('input[name="password"]', password);
|
||||
await page.press('input[name="password"]', "Enter");
|
||||
}
|
||||
|
||||
class LoginPage {
|
||||
async login(page, email, password) {
|
||||
await page.getByText("Login").click();
|
||||
await page.getByText("Login with Email").click();
|
||||
await page.fill('input[name="email"]', email);
|
||||
await page.press('input[name="email"]', "Tab");
|
||||
await page.fill('input[name="password"]', password);
|
||||
await page.press('input[name="password"]', "Enter");
|
||||
}
|
||||
}
|
||||
|
||||
test("create account, login, and complete onboarding", async ({ page }) => {
|
||||
const loginPage = new LoginPage();
|
||||
await createUser(page);
|
||||
await loginPage.login(page, email, password);
|
||||
await completeOnboarding(page);
|
||||
});
|
||||
await expect(page).toHaveTitle(/Your Surveys | Formbricks/);
|
||||
}
|
||||
|
||||
test("create account, login, and complete onboarding", async ({ page }) => {
|
||||
await createUser(page);
|
||||
await loginUser(page);
|
||||
await completeOnboarding(page);
|
||||
});
|
||||
@@ -119,7 +119,8 @@
|
||||
"S3_SECRET_KEY",
|
||||
"S3_REGION",
|
||||
"S3_BUCKET_NAME",
|
||||
"ENTERPRISE_LICENSE_KEY"
|
||||
"ENTERPRISE_LICENSE_KEY",
|
||||
"PLAYWRIGHT_CI"
|
||||
]
|
||||
},
|
||||
"post-install": {
|
||||
|
||||
Reference in New Issue
Block a user